diff --git a/.changeset/astro-7-website.md b/.changeset/astro-7-website.md new file mode 100644 index 000000000..c27d1329f --- /dev/null +++ b/.changeset/astro-7-website.md @@ -0,0 +1,8 @@ +--- +--- + +fix(deps): astro 7.4.4 and sharp 0.35.5 in website/site + +No published package changes: `website/site` is a private workspace package and +is not part of the release set, so this changeset is empty on purpose. The bump +closes a critical advisory in astro (< 7.2.8) and three highs in astro and sharp. diff --git a/.factory/config.json b/.factory/config.json new file mode 100644 index 000000000..381a30e4c --- /dev/null +++ b/.factory/config.json @@ -0,0 +1,3 @@ +{ + "integrationBase": "rewrite/main" +} diff --git a/.github/assets/logo.jpg b/.github/assets/logo.jpg new file mode 100644 index 000000000..eda6644dd Binary files /dev/null and b/.github/assets/logo.jpg differ diff --git a/.github/assets/logo.png b/.github/assets/logo.png new file mode 100644 index 000000000..aa5bfa44f Binary files /dev/null and b/.github/assets/logo.png differ diff --git a/.github/scripts/deploy-with-retry.sh b/.github/scripts/deploy-with-retry.sh new file mode 100755 index 000000000..91d9d9168 --- /dev/null +++ b/.github/scripts/deploy-with-retry.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# +# Run a wrangler deploy, retrying only a transient Cloudflare failure. +# +# .github/scripts/deploy-with-retry.sh +# +# The command is taken from the caller so each workflow keeps its own +# invocation (`npx wrangler deploy --config wrangler.preview.jsonc` for a +# preview, `pnpm exec wrangler deploy --config wrangler.router.jsonc` with +# `--var` for production). +# +# Only three signals are retried, because retrying anything else triples the +# noise and delays the message that matters: +# +# - `code: 10013`, the Cloudflare API's "unknown error" seen in #970 +# - an HTTP 5xx from the Cloudflare API +# - a connection reset +# +# An expired per-attempt timeout is retried too: a hung upload is the case the +# timeout exists for, and failing on the first one would defeat it. +# +# Authentication failures, a missing environment and wrangler validation +# errors fail on the first attempt. +# +# Every attempt after the first is announced with a `::warning::` naming the +# attempt and the reason, so the flake rate stays visible in the Actions UI +# instead of disappearing behind a passing job. +# +# Environment: +# DEPLOY_ATTEMPTS total attempts (default 3) +# DEPLOY_ATTEMPT_TIMEOUT seconds allowed per attempt (default 180) +# DEPLOY_BACKOFFS seconds to wait before each retry (default "10 30") +set -uo pipefail + +attempts=${DEPLOY_ATTEMPTS:-3} +attempt_timeout=${DEPLOY_ATTEMPT_TIMEOUT:-180} +read -r -a backoffs <<< "${DEPLOY_BACKOFFS:-10 30}" + +if [ "$#" -eq 0 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +if ! [[ "$attempts" =~ ^[1-9][0-9]*$ ]]; then + echo "DEPLOY_ATTEMPTS must be a positive integer, got '$attempts'" >&2 + exit 2 +fi + +# `timeout` is coreutils: every GitHub runner has it, a stock macOS does not. +# Falling back keeps the script usable from a laptop (the unit suite runs it), +# and says so rather than silently dropping the bound. Bash 3.2 ships on macOS, +# so an empty array cannot be expanded with "${timeout_cmd[@]}" under `set -u`. +timeout_cmd=() +if command -v timeout >/dev/null 2>&1; then + timeout_cmd=(timeout --kill-after=10 "$attempt_timeout") +elif command -v gtimeout >/dev/null 2>&1; then + timeout_cmd=(gtimeout --kill-after=10 "$attempt_timeout") +else + echo "::warning::no 'timeout' or 'gtimeout' on PATH — deploy attempts are not bounded (install coreutils to restore the per-attempt limit)" +fi + +logs=$(mktemp -d) +trap 'rm -rf "$logs"' EXIT + +# Echo the transient reason found in an attempt's output, or return 1 when the +# failure is one we must not retry. +transient_reason() { + local log=$1 + local http + + if grep -qE 'code: ?10013' "$log"; then + printf 'Cloudflare API error code 10013' + return 0 + fi + http=$(grep -oE 'HTTP 5[0-9][0-9]' "$log" | head -1 || true) + if [ -n "$http" ]; then + printf '%s from the Cloudflare API' "$http" + return 0 + fi + if grep -qiE 'ECONNRESET|connection reset|socket hang up' "$log"; then + printf 'connection reset' + return 0 + fi + + return 1 +} + +for (( attempt = 1; attempt <= attempts; attempt++ )); do + log="$logs/attempt-$attempt.log" + status=0 + echo "deploy attempt $attempt/$attempts: $*" + # The whole attempt is bounded: an upload that stops making progress must not + # hold the job until the workflow's own timeout. `--kill-after` covers a + # process that ignores TERM. Without coreutils the bound is dropped and the + # warning above says so, rather than turning every deploy into exit 127. + ${timeout_cmd[@]+"${timeout_cmd[@]}"} "$@" >"$log" 2>&1 || status=$? + cat "$log" + + if [ "$status" -eq 0 ]; then + echo "deploy succeeded on attempt $attempt/$attempts" + exit 0 + fi + + reason=$(transient_reason "$log") || reason= + if [ -z "$reason" ]; then + if [ "$status" -eq 124 ] || [ "$status" -eq 137 ]; then + reason="attempt timed out after ${attempt_timeout}s" + else + echo "::error::deploy failed on attempt $attempt/$attempts with a non-transient error (exit $status) — not retrying" >&2 + tail -n 30 "$log" >&2 + exit "$status" + fi + fi + + if [ "$attempt" -ge "$attempts" ]; then + echo "::error::deploy failed after $attempts attempts — last failure: $reason" >&2 + tail -n 30 "$log" >&2 + exit "$status" + fi + + delay=${backoffs[$((attempt - 1))]:-0} + echo "::warning::wrangler deploy attempt $((attempt + 1))/$attempts after attempt $attempt/$attempts failed with: $reason — waiting ${delay}s" + if [ "$delay" -gt 0 ]; then + sleep "$delay" + fi +done diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 17e64508f..fd73c1200 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -43,6 +43,13 @@ jobs: - '.nvmrc' - 'resources/default-settings.yaml' - '.github/workflows/checks.yml' + # A unit test reads these workflows, so a change to any of them + # must run the suite that asserts on it. #965 changed release.yml + # and skipped the very tests that change broke. + - '.github/workflows/**' + # test/unit/scripts/deploy-with-retry.spec.ts runs the deploy + # script, so a change to it must run the suite that asserts on it. + - '.github/scripts/**' rust: - 'crates/**' - 'Cargo.toml' @@ -123,7 +130,8 @@ jobs: if: | needs.changes.outputs.src == 'true' && needs.lint.result == 'success' && - needs.build-check.result == 'success' + needs.build-check.result == 'success' && + github.head_ref != 'changeset-release/main' steps: - name: Checkout uses: actions/checkout@v4 @@ -166,7 +174,8 @@ jobs: if: | needs.changes.outputs.src == 'true' && needs.lint.result == 'success' && - needs.build-check.result == 'success' + needs.build-check.result == 'success' && + github.head_ref != 'changeset-release/main' steps: - name: Checkout uses: actions/checkout@v4 @@ -293,60 +302,3 @@ jobs: run: pnpm install --frozen-lockfile - name: Check for changeset run: pnpm exec changeset status --since origin/${{ github.base_ref }} - - publish-container-image: - name: Build and push container image - runs-on: ubuntu-latest - needs: - - changes - - lint - - build-check - - test-units-and-cover - - test-integrations-and-cover - - post-tests - if: >- - always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && - needs.changes.result == 'success' && - ( - needs.changes.outputs.src != 'true' || - ( - needs.lint.result == 'success' && - needs.build-check.result == 'success' && - needs.test-units-and-cover.result == 'success' && - needs.test-integrations-and-cover.result == 'success' - ) - ) && - needs.post-tests.result == 'success' - env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Log in to the Container registry - uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=raw,value=main - type=sha,prefix=sha- - - - name: Build and push Docker image - uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/deploy-website-preview.yml b/.github/workflows/deploy-website-preview.yml new file mode 100644 index 000000000..5c586bd44 --- /dev/null +++ b/.github/workflows/deploy-website-preview.yml @@ -0,0 +1,258 @@ +name: Deploy Website Preview + +# Per-PR preview of the marketing site at https://ema.tnsor.network/preview// +# (the production router worker proxies /preview/ to this PR's worker, +# nostream-web-pr-, served from its workers.dev URL). The preview is deleted +# when the PR closes (merged or not). +# +# Requires the `website-preview` GitHub environment to exist with +# CLOUDFLARE_API_TOKEN (Workers Scripts edit) and CLOUDFLARE_ACCOUNT_ID — +# one-time setup. While the environment or its secrets are absent, this +# workflow skips with a notice instead of failing the PR. +# +# Ordering rule for this workflow: the deploy runs FIRST among the side +# effects, and every GitHub-comment step is cosmetic (`continue-on-error`). +# Measured 2026-10-18: a pull request opened from a FORK gets a read-only +# GITHUB_TOKEN whatever the `permissions:` block says, so its comment call +# answers `GraphQL: Resource not accessible by integration (addComment)` — +# and because that step ran BEFORE the deploy, run 39134047965 failed at +# 14:01Z with the deploy skipped, leaving the previous build being served. +# A comment must never be able to prevent a deploy. + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - closed + paths: + - "website/site/**" + - "website/hero-animation/**" + - ".github/workflows/deploy-website-preview.yml" + # The production workflow deploys the router that serves /preview/*, so + # a router change has to refresh the previews or they keep serving the + # old routing. (This file's own entry is what made run 39134047965 fire + # at all: PR 885 touches none of the website paths.) + - ".github/workflows/deploy-website.yml" + - "website/site/worker/**" + # The deploy step above runs this script; a change to it changes how the + # preview is deployed, so the previews must be rebuilt by it. + - ".github/scripts/**" + +concurrency: + group: website-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + # PR conversation comments are issue comments: `gh pr comment` and the + # `issues//comments` API need this, and `pull-requests: write` alone + # does not grant it. Same-repo runs worked only because the repo's default + # GITHUB_TOKEN permissions already included it; a fork PR never does. + issues: write + +jobs: + preview: + runs-on: ubuntu-latest + # Deploy credentials live in this GitHub environment: CLOUDFLARE_API_TOKEN + # secret (Workers Scripts scope) and CLOUDFLARE_ACCOUNT_ID variable. + environment: website-preview + # Guard the environment reference so a missing setup skips rather than fails: + # the job targets the environment only when it exists. + steps: + - name: Check preview environment is configured + id: guard + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + # Existence is the only check the runner's token can make: listing + # environment secrets needs permissions github.token does not carry, + # and probing them here once made every run skip with "lacks + # CLOUDFLARE_API_TOKEN" even after the secrets were set. + # A pull_request run from a fork gets no repository secrets at all - + # GitHub does not hand them to code the PR author controls, whatever + # the environment says. Such a run can never deploy a preview, so it + # must stop here rather than reach `wrangler deploy` with an empty + # token and fail there. The fix that would serve forks is a + # workflow_run deployment on a trusted branch; this workflow does + # not implement one, so it skips instead of pretending. + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "::notice::pull request is from a fork — preview skipped (fork runs receive no repository secrets). The site deploy runs on main after merge." + elif gh api repos/${{ github.repository }}/environments/website-preview >/dev/null 2>&1; then + echo "skip=false" >> "$GITHUB_OUTPUT" + else + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "::notice::website-preview environment does not exist yet — PR preview skipped. Create it with CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID to enable previews." + fi + + - name: Checkout (for cleanup metadata) + if: steps.guard.outputs.skip == 'false' + uses: actions/checkout@v3 + + - uses: pnpm/action-setup@v4 + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + + - uses: actions/setup-node@v3 + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install workspace dependencies + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + run: pnpm install --frozen-lockfile + + - name: Build the site under the preview base + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + working-directory: website/site + env: + # Previews are built under the preview domain, not the production + # one: a preview's absolute links and canonical tags must not claim + # nostream.dev, or a crawler reaching a preview caches the wrong + # origin. Not a repo variable - previews are our own infrastructure, + # and a variable someone forgets to set would silently point them + # at production. + SITE_URL: https://ema.tnsor.network + SITE_BASE: /preview/${{ github.event.pull_request.number }}/ + run: | + pnpm run build + node scripts/apply-base.mjs + # The assets manifest is keyed by file path: the worker serves dist/ + # at the root of its URL, but the preview is requested under + # /preview//... — so the built tree must also EXIST at that path + # or every asset request falls through to the SPA-fallback + # index.html (text/html for .css/.mp4 requests — the broken preview). + # Copy the build into dist/preview// (excluding the copy itself). + n="${{ github.event.pull_request.number }}" + mkdir -p "dist/preview/$n" + find dist -mindepth 1 -maxdepth 1 ! -name preview -exec cp -r {} "dist/preview/$n/" \; + + - name: Deploy the preview worker + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + working-directory: website/site + # A transient Cloudflare failure must not leave the previous build being + # served while the PR reads as deployed (#970). The shared script retries + # code 10013, an HTTP 5xx and a reset connection — about a minute of + # backoff in total — and fails on the first attempt for anything else, + # including an auth failure. timeout-minutes bounds the retries plus the + # per-attempt timeout the script applies. + timeout-minutes: 12 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + bash "$GITHUB_WORKSPACE/.github/scripts/deploy-with-retry.sh" \ + npx wrangler deploy \ + --config wrangler.preview.jsonc \ + --name nostream-web-pr-${{ github.event.pull_request.number }} + + - name: Purge the preview's CDN cache + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + # Part of making a deploy visible, never a gate on it: a missing or + # under-scoped purge credential must not fail a job whose deploy + # already succeeded. + continue-on-error: true + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ZONE_ID: ${{ vars.CLOUDFLARE_ZONE_ID }} + run: | + # A newly deployed preview worker sits behind the CDN for its own + # domain, so a request that landed mid-deploy can keep being served + # the previous build after the new one is live. Measured 2026-10-18: + # https://ema.tnsor.network/preview/885/ returned production HTML + # with no x-preview-pr header at 14:01:53Z and answered correctly + # three minutes later — a stale window, not a broken deploy. + # + # The purge is scoped to this PR's path prefix, so it cannot affect + # the production site or another PR's preview. + n="${{ github.event.pull_request.number }}" + if [ -z "$CLOUDFLARE_ZONE_ID" ]; then + echo "::notice::CLOUDFLARE_ZONE_ID is not set for the website-preview environment — preview deployed, cache purge skipped. Add it as an environment VARIABLE (the zone id is on the ema.tnsor.network zone Overview page) to enable purging." + exit 0 + fi + # Hostname and path only: a scheme-prefixed value is rejected outright. + body="{\"prefixes\":[\"ema.tnsor.network/preview/$n/\"]}" + code=$(curl -sS -o /tmp/purge.json -w '%{http_code}' \ + -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \ + -H "authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + -H 'content-type: application/json' \ + --data "$body") + if [ "$code" = "200" ]; then + echo "purged https://ema.tnsor.network/preview/$n/ (prefix)" + else + echo "::warning::cache purge answered HTTP $code — $(head -c 400 /tmp/purge.json)" + fi + + # The comment is written ONCE, after the deploy, in its final state. A + # separate "rendering" write that a later step has to undo would leave a + # finished preview reading as unfinished whenever the second write failed + # or the run was cancelled - and it no longer signals anything useful now + # that it runs after the deploy rather than before it. + - name: Link the preview on the PR + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + URL: https://ema.tnsor.network/preview/${{ github.event.pull_request.number }}/ + WORKER_URL: https://nostream-web-pr-${{ github.event.pull_request.number }}.cameri.workers.dev/preview/${{ github.event.pull_request.number }}/ + MARKER: + run: | + # One comment per PR, updated in place on every push: find the + # existing one by marker, edit it, or create it if absent. The + # direct workers.dev URL works as soon as this deploy lands; the + # ema.tnsor.network form starts working once the router ships + # (it deploys with this PR's merge). + COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments?per_page=100" \ + --jq '.[] | select(.body | startswith(env.MARKER)) | .id' | tail -1) + BODY="${MARKER} + 🔍 Site preview for this PR (includes all changes on this branch): ${WORKER_URL} + + The ${URL} form serves the same build once this PR merges — the production router that proxies /preview/* ships with it. + + Updates with every push to this PR; deleted when the PR closes." + if [ -n "$COMMENT_ID" ]; then + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$BODY" >/dev/null + echo "updated comment $COMMENT_ID" + else + gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body "$BODY" >/dev/null + echo "created new preview comment" + fi + + - name: Delete the preview worker + if: steps.guard.outputs.skip == 'false' && github.event.action == 'closed' + working-directory: website/site + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + # --force skips the "would this break dependents" probe, which calls + # KV namespaces listing the deploy token's scope (Workers Scripts + # only) cannot read — the probe errors with auth 10000 AFTER the + # delete has already succeeded. Preview workers are assets-only + # (no bindings), so the check is pure overhead. A missing worker + # still errors and is caught below. + npx wrangler delete --config wrangler.preview.jsonc --force \ + --name nostream-web-pr-${{ github.event.pull_request.number }} \ + || echo "preview worker already absent" + + - name: Note the cleanup on the PR + if: steps.guard.outputs.skip == 'false' && github.event.action == 'closed' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + MARKER: + run: | + # Update the SAME single preview comment instead of adding one. + COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments?per_page=100" \ + --jq '.[] | select(.body | startswith(env.MARKER)) | .id' | tail -1) + BODY="${MARKER} + 🧹 Site preview for this PR was deleted (PR closed)." + if [ -n "$COMMENT_ID" ]; then + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$BODY" >/dev/null + else + gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body "$BODY" >/dev/null || true + fi diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml new file mode 100644 index 000000000..21ea84f76 --- /dev/null +++ b/.github/workflows/deploy-website.yml @@ -0,0 +1,108 @@ +name: Deploy Website + +# The marketing site deploys when a nostream-website release is published: +# the changesets release PR bumps the site package, and merging that PR makes +# the changesets workflow tag + publish nostream-website@X.Y.Z and create the +# GitHub release — this workflow deploys that tagged build. Merges to main do +# not deploy; version the site with a changeset and merge the release PR. + +on: + release: + types: + - published + workflow_dispatch: + +jobs: + deploy: + # Concurrency lives on the job, not the workflow: a relay release + # (v* tag) fails the if below and must not enter this deploy queue, + # where it could replace a queued website deploy. Measured: the + # nostream@3.5.1 release run (38845857055) was cancelled with zero + # jobs at the workflow-level group, before the job if could skip it. + concurrency: + group: deploy-website + cancel-in-progress: false + # workflow_dispatch ignores every on: filter — a manual run must not + # publish a non-main branch to production, so dispatch stays main-only. + if: >- + ${{ startsWith(github.event.release.tag_name, 'nostream-website@') || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }} + name: Build and deploy to Cloudflare Workers + runs-on: ubuntu-latest + # Deploy credentials live in this GitHub environment: CLOUDFLARE_API_TOKEN + # secret (Workers Scripts scope) and CLOUDFLARE_ACCOUNT_ID variable. + environment: website + permissions: + contents: read + steps: + # Checkout gets the release event's default: the tag that triggered the + # run (refs/tags/nostream-website@X.Y.Z). The website environment's + # branch policy admits nostream-website@* — every release deploy since + # 1.1.1 has run on the tag ref — so the build is exactly the released + # commit, even if main has advanced by the time the job starts. + - name: Checkout + uses: actions/checkout@v3 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v3 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Build the site + working-directory: website/site + env: + SITE_URL: https://nostream.dev + run: pnpm run build + + - name: Deploy to Cloudflare Workers + working-directory: website/site + # The shared script retries only a transient failure (#970) and fails on + # the first attempt for an auth or validation error; timeout-minutes + # bounds the retries plus the per-attempt timeout the script applies. + timeout-minutes: 12 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CANONICAL_HOST: nostream.dev + # The router worker: serves the production assets itself and proxies + # /preview/ to per-PR preview workers (deploy-website-preview.yml). + # The site answers on one host. Every other host the routes catch, + # http included, 301s to it, so www/apex cannot serve a second copy + # and split search ranking. --var keeps the deployed value from + # drifting away from the SITE_URL the build above baked in. + run: | + bash "$GITHUB_WORKSPACE/.github/scripts/deploy-with-retry.sh" \ + pnpm exec wrangler deploy --config wrangler.router.jsonc --var CANONICAL_HOST:"$CANONICAL_HOST" + + - name: Purge the site's CDN cache + # Same reason as the preview workflow's purge: a request that lands + # mid-deploy can keep being served the previous build after the new + # one is live. Production deploys the router, so everything changes + # and the purge is unfiltered (`purge_everything`). Guarded on the + # variable so an unconfigured environment skips instead of POSTing to + # a zone id of "" and the notice says what is missing. + if: vars.CLOUDFLARE_ZONE_ID != '' + continue-on-error: true + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ZONE_ID: ${{ vars.CLOUDFLARE_ZONE_ID }} + run: | + code=$(curl -sS -o /tmp/purge.json -w '%{http_code}' \ + -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \ + -H "authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + -H 'content-type: application/json' \ + --data '{"purge_everything":true}') + if [ "$code" = "200" ]; then + echo "purged the whole nostream.dev cache" + else + echo "::warning::cache purge answered HTTP $code — $(head -c 400 /tmp/purge.json)" + fi + + - name: Note a skipped purge + if: vars.CLOUDFLARE_ZONE_ID == '' + run: echo "::notice::CLOUDFLARE_ZONE_ID is not set for the website environment — deployed, cache purge skipped. Add it as an environment VARIABLE to enable purging." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93797b962..8d43b158e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,9 +3,25 @@ name: Release on: release: types: [published] + # A published release is immutable, so a build that fails while publishing it + # cannot be retried by re-publishing the event that triggered it — GitHub + # refuses the draft/publish round trip with `immutable cannot be changed when + # release is immutable`. This is the only path to (re)build an image for a tag + # whose release already exists (#964). + workflow_dispatch: + inputs: + tag: + description: 'Relay release tag to build, e.g. nostream@3.7.0' + required: true + type: string jobs: build-and-push-image: + # Changesets tags each workspace package on publish: relay releases tag + # nostream@X.Y.Z, website releases tag nostream-website@X.Y.Z. Only a relay + # tag should build the relay container image. The v* branch is kept for the + # releases cut before the repo moved to changesets tagging. + if: github.event_name == 'workflow_dispatch' || startsWith(github.event.release.tag_name, 'nostream@') || startsWith(github.event.release.tag_name, 'v') name: Build and push container image runs-on: ubuntu-latest env: @@ -17,6 +33,12 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v3 + # Build the code the tag names, not the branch the dispatch was started + # from. A release event already carries the tag as GITHUB_REF, so the + # input only overrides it; checking out `main` while pushing the image as + # `3.7.0` would publish the wrong source under a release version. + with: + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} - name: Log in to the Container registry uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 with: @@ -28,10 +50,66 @@ jobs: uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # The image tags are computed here rather than by metadata-action's rules + # because a changesets relay release is tagged `nostream@3.7.0`, and `@` is + # not legal in a Docker tag: it separates the repository path from a digest + # in an image reference. A `type=ref,event=tag` rule emitted that string + # verbatim, and buildx refused the whole build with `invalid tag ...: + # invalid reference format`, so a published release produced no image at + # all (#964). Nothing in the action's output is legal just because the + # action emitted it, so every tag is checked against Docker's grammar here + # and an illegal one fails by name instead of at buildx. + - name: Compute image tags + id: tags + shell: bash + env: + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + PRERELEASE: ${{ github.event.release.prerelease }} + run: | + set -euo pipefail + image="$REGISTRY/$IMAGE_NAME" + # A dispatch has no release flags, so a tag that is not a relay release + # is refused here rather than built: `github.event.release.tag_name` is + # empty, and only this check stands between a typo and a pushed image. + case "$RELEASE_TAG" in + nostream@*|v*) ;; + *) echo "::error::not a relay release tag: '$RELEASE_TAG'"; exit 1 ;; + esac + # `nostream@3.7.0` -> `3.7.0`; the pre-changesets form `v3.4.1` -> `3.4.1`. + version="${RELEASE_TAG##*@}" + version="${version#v}" + tags="$image:$version" + # A legacy `vX.Y.Z` release also keeps the form hosts pinned before the + # changesets migration. A changesets tag has no legal form of its own. + case "$RELEASE_TAG" in + v*) tags="$tags,$image:$RELEASE_TAG" ;; + esac + # `latest` is the tag both deploy stacks pull, so a prerelease must not + # move it onto a test build. A release event states this directly; a + # dispatch does not, so a pre-release version is read from the tag. + prerelease="${PRERELEASE:-}" + if [ -z "$prerelease" ]; then + case "$version" in + *-*) prerelease=true ;; + *) prerelease=false ;; + esac + fi + if [ "$prerelease" != "true" ]; then + tags="$tags,$image:latest" + fi + for tag in ${tags//,/ }; do + name="${tag##*:}" + if ! printf '%s' "$name" | grep -qE '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$'; then + echo "::error::'$name' is not a legal Docker tag (release tag: $RELEASE_TAG)" + exit 1 + fi + done + echo "tags=$tags" >> "$GITHUB_OUTPUT" + echo "publishing: $tags" - name: Build and push Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc with: context: . push: true - tags: ${{ steps.meta.outputs.tags }} + tags: ${{ steps.tags.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.husky/install.mjs b/.husky/install.mjs index 2f196fc50..b22f9141e 100644 --- a/.husky/install.mjs +++ b/.husky/install.mjs @@ -1,18 +1,30 @@ import { existsSync } from 'node:fs'; import { execSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); // Skip Husky installation in environments where hooks are not needed if ( process.env.NODE_ENV === 'production' || process.env.CI === 'true' || process.env.HUSKY === '0' || - !existsSync('.git') + !existsSync(path.join(process.cwd(), '.git')) ) { process.exit(0); } +const require = createRequire(path.join(repoRoot, 'package.json')); +try { + require.resolve('husky'); +} catch { + process.exit(0); +} + try { - execSync('npx husky install', { stdio: 'ignore' }); + execSync('pnpm exec husky install', { cwd: repoRoot, stdio: 'ignore', timeout: 8000 }); } catch { process.exit(0); } diff --git a/.knip.json b/.knip.json index 4151e7b65..d84dc2d3d 100644 --- a/.knip.json +++ b/.knip.json @@ -1,28 +1,48 @@ { "$schema": "https://unpkg.com/knip@2/schema.json", - "entry": [ - "src/index.ts!", - "src/import-events.ts!", - "src/cli/index.ts!", - "src/scripts/benchmark-queries.ts!", - "knexfile.js!" - ], - "project": [ - "src/**/*.ts!" - ], - "ignoreDependencies": [ - "lzma-native" - ], - "ignore": [ - ".nostr/**", - "src/utils/negentropy.ts", - "src/utils/relay-probe/**" - ], - "commitlint": false, - "eslint": false, - "github-actions": false, - "husky": false, - "mocha": false, - "nyc": false, - "semantic-release": false + "workspaces": { + ".": { + "entry": [ + "src/index.ts!", + "src/import-events.ts!", + "src/cli/index.ts!", + "src/scripts/benchmark-queries.ts!", + "knexfile.js!" + ], + "project": [ + "src/**/*.ts!" + ], + "ignoreDependencies": [ + "lzma-native" + ], + "ignore": [ + ".nostr/**", + "src/utils/relay-probe/**" + ], + "commitlint": false, + "eslint": false, + "github-actions": false, + "husky": false, + "mocha": false, + "nyc": false, + "semantic-release": false + }, + "website/site": { + "entry": [ + "astro.config.mjs", + "src/**/*.{astro,ts,mjs}", + "scripts/*.mjs", + "worker/**/*.ts" + ], + "ignoreDependencies": [ + "@fontsource-variable/inter", + "@fontsource/jetbrains-mono", + "astro", + "sharp", + "wrangler", + "@astrojs/markdown-remark" + ], + "astro": true + } + } } diff --git a/.nvmrc b/.nvmrc index a3b7a31ee..474d750b5 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.14.1 +v24.22.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index bb87430ab..5a6fde675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,280 @@ # nostream +## 3.11.0 + +### Minor Changes + +- [#1030](https://github.com/cameri/nostream/pull/1030) [`9b585db`](https://github.com/cameri/nostream/commit/9b585db93796b4149d3932487686a46a597b7643) Thanks [@Ferryx349](https://github.com/Ferryx349)! - feat(peering): authenticate peer relays with NIP-42 using the relay keypair + + Adds the peering identity layer: a configured `peering.peers` trust list keyed + by relay pubkey (hex or `npub`), outbound NIP-42 AUTH signed with the relay's own + keypair, and inbound recognition of a verified peer pubkey on the connection + state. Peer status is tied to the NIP-42 session, so it expires or clears with + the socket; a valid AUTH from an unconfigured pubkey stays an ordinary client. + + `peering.peers[].relayUrl` is the outbound address only — identity is always the + verified pubkey, never the URL. Relay key storage and the AUTH exchange are + documented in `docs/PEERING.md`, and the new fields are validated with the rest + of the settings document. + +### Patch Changes + +- [#1024](https://github.com/cameri/nostream/pull/1024) [`eb304b8`](https://github.com/cameri/nostream/commit/eb304b80c9cb997634509df8ef8155fe8ef619dd) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(deps): bump knex to 3.2.9 for the search_path SQL injection (AIKIDO-2026-10469) + + knex 0.0.1–3.2.8 interpolates values passed to `searchPath` for the Postgres + dialect into generated SQL without validating or escaping them, so a value an + attacker can influence becomes an injected identifier. 3.2.9 escapes the double + quotes ([#6411](https://github.com/cameri/nostream/issues/6411)); this repository declares `knex` 2.4.2 on both the default branch + and `rewrite/main`. + + nostream never sets `searchPath` — `grep -rn "search_path" src/` finds nothing — + so the reachable path is not exercised today, and the bump is taken to remove a + vulnerable dependency from the image, not to close a reachable hole. + + knex 3.0.0 drops Node < 16, which the repo already requires (>=24.14.1), and 3.2.0 + tightens `RawBinding`. The one compile error that surfaced, + `src/scripts/benchmark-queries.ts` passing `bindings as readonly unknown[]` to + `db.raw()`, is fixed by dropping the assertion: `toSQL().bindings` is already the + accepted type. The emitted JavaScript is byte-identical with and without the + assertion. + +## 3.10.2 + +### Patch Changes + +- [#986](https://github.com/cameri/nostream/pull/986) [`3696f3e`](https://github.com/cameri/nostream/commit/3696f3e418063418e027cd2a5756dfe19fe8ad58) Thanks [@Ferryx349](https://github.com/Ferryx349)! - fix(admin): Greptile follow-ups for moderation limits and activity stats + + Moderation limit PATCH and ban/unban actions could interleave read–update–save across workers and drop updates. Those paths now run under an async settings write lock that yields while waiting and reclaims stale locks without deleting a fresh holder’s file. Activity stats windows filter on `first_seen` (when the relay first saw the event), not `event_created_at`, with partial indexes on live rows for those queries. IPv6 addresses on the rate-limit bypass list are normalized the same way as connection IPs so bypass rules cannot be skipped by formatting, including when removing entries stored in another spelling. PATCH rejects kind ranges with `start` greater than `end`. PATCH and moderation actions return the normalized limits read back from settings after save. + +## 3.10.1 + +### Patch Changes + +- [#1011](https://github.com/cameri/nostream/pull/1011) [`fd7679b`](https://github.com/cameri/nostream/commit/fd7679bed7ba2b10f5148f50ece415e869814475) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(rate-limit): send the backoff the strategy actually means, name an outage as an error, and bucket messages by type + + Three defects on one wire path, from the spike in [#1004](https://github.com/cameri/nostream/issues/1004). + + **The EWMA backoff was a half-life, sent as a delay.** `RateLimit.period` is the decay half-life under `ewma`, so the time until a limit lifts is `period · log2(R_new / rate)`, not `period`. With `rate: 6, period: 60000`, a client four times over the limit was told to wait 60 s and needed 120 s. The Lua script now returns its score instead of a yes/no, and the limiter returns `period · log2(score / rate)`. It was only accidentally safe before: the hash carried a TTL of exactly `period`, so a quiet client's state expired anyway — an unstated coupling that would break the moment anyone lengthened the TTL to keep state warm. + + **A limiter outage was announced as `rate-limited`.** A failing backend made every connected client back off for a fault on this side. It now answers `error: rate limiter unavailable` and still rejects the event; fail-closed behaviour is unchanged. + + **The message bucket ignored the message type.** One bucket per client meant a `REQ` could be refused because its `EVENT`s had filled it — the wrong message throttled, and the wrong one told to back off. The bucket key now carries the type, read from a bounded prefix of the frame because the limiter runs before validation. + + `IRateLimiter.hit` returns `IRateLimiterResult` (`limited`, plus `retryAfterMs` when the strategy can estimate one) so each strategy answers in its own terms; a caller with no estimate keeps sending the period. + +- [#1012](https://github.com/cameri/nostream/pull/1012) [`d8095de`](https://github.com/cameri/nostream/commit/d8095de9bdf8f5174c62cc2f1372472731e286ae) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(startup): print the banner as text, and let `LOG_FORMAT=json` opt out of it + + `docker compose logs nostream` showed the logo as a single log record with the newlines escaped. The banner and its centred facts now go to stdout as text. + + A pipeline that requires every line to parse sets `LOG_FORMAT=json`: the logo is then replaced by one `relay starting` record carrying the same facts as fields (`version`, `supportedNips`, `payments`, `eventStore`, `clientWorkers`, …). Any other value keeps the banner, which is what a person reading the logs wants. + +## 3.10.0 + +### Minor Changes + +- [#922](https://github.com/cameri/nostream/pull/922) [`fcb33de`](https://github.com/cameri/nostream/commit/fcb33de228c9d53fd0a2dcae412996563ca1ed99) Thanks [@Priyanshubhartistm](https://github.com/Priyanshubhartistm)! - feat(payments): add per-plan event rate limits and retention + +### Patch Changes + +- [#1009](https://github.com/cameri/nostream/pull/1009) [`ad8c5c4`](https://github.com/cameri/nostream/commit/ad8c5c4b9d2e6df34704741465d2505d811fe7b7) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - build: pin the node base image in every file that names one + + `Dockerfile` pinned `node:24.22.1-alpine` by digest, but `Dockerfile.railwayapp`, `Dockerfile.test`, `docker-compose.yml` and `test/integration/docker-compose.yml` still said `node:24-alpine`. The relay image, the CI image, the Railway image and both migration containers could each start from a different base, and a floating tag moves under a release — so the same commit built twice could run different node code without a change saying so. + + All five now carry the same tag and digest, and a unit test holds them there: it fails if any reference loses its digest, and it fails if two different references appear. + +- [#1010](https://github.com/cameri/nostream/pull/1010) [`fa54cb0`](https://github.com/cameri/nostream/commit/fa54cb074beda6af556c93c25a7f77f569dc82dd) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(settings): state that rate-limit periods are milliseconds and reject a period that is not one + + Every shipped limit counts in milliseconds — `1000`, `60000`, `900000`, `3600000` — but nothing said so. `RateLimit.period` is a bare `number`, so an operator writing `period: 60` for a minute got a 60 ms window — and because `rate` is hits per window, that is a limit a thousand times **looser** than intended, honoured without complaint, and caught by nothing. + + The unit is now stated on `RateLimit` and on every `rateLimits` block in `resources/default-settings.yaml`, and `validateRateLimitPeriods` rejects what cannot be a count of milliseconds: a fraction, a string, a non-number, or anything below one millisecond. A whole number is not enough to tell 60 from 60000, which is why the unit is documented where it is set. + +## 3.9.0 + +### Minor Changes + +- [#999](https://github.com/cameri/nostream/pull/999) [`469f1b1`](https://github.com/cameri/nostream/commit/469f1b17851c1e8be1836c3e49b0d248ca5df306) Thanks [@Ferryx349](https://github.com/Ferryx349)! - feat(peering): advertise peering terms and scopes in NIP-11 + + The relay information document now carries a `peering` section built from the new + `peering` settings block. It advertises whether peering is offered, the feeds + available as `kinds` scopes with an absolute `since`/`until` range or a rolling + `windowSeconds`, terms in the existing NIP-11 fee-object shape (`amount`, `unit`, + `period`) where an empty `terms` list means settlement-free, a peer `contact` or + WebSocket `endpoint`, and `min_pow_difficulty` — the NIP-13 work a peering request + must carry, `0` meaning none. + + Disabled peering publishes only `{ "enabled": false }`, so half-configured terms are + never read as an accepted peer: this is the discovery surface other relays read + before connecting, not a peer-acceptance path. Settings validation rejects invalid + scopes, terms, time ranges, endpoints and proof-of-work bounds with a clear path, and + all `peering.*` fields are documented in `CONFIGURATION.md` with an example document. + + Closes [#932](https://github.com/cameri/nostream/issues/932). + +- [#926](https://github.com/cameri/nostream/pull/926) [`2819e30`](https://github.com/cameri/nostream/commit/2819e3044aef89298955c4aef6272a1c7132ff09) Thanks [@Priyanshubhartistm](https://github.com/Priyanshubhartistm)! - feat(payments): let clients see their subscription tier + +### Patch Changes + +- [#972](https://github.com/cameri/nostream/pull/972) [`a46ae06`](https://github.com/cameri/nostream/commit/a46ae06afc508735eac23f7a8eb4b7f7fbfb400e) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(compose): keep dynamic container addresses off the static ones + + `docker-compose.yml` pins `nostream`, `nostream-db` and `nostream-cache` to `10.10.10.2`–`.4`, but dynamic allocation drew from the bottom of the same `/24`. The observability stack has no dependency that orders it after `nostream`, so `otel-collector` could be given `.2` and `nostream` then failed to start with `Address already in use` — most reliably after a host reboot, when the daemon starts containers in arbitrary order. + + Dynamic allocation is now confined to `10.10.10.128/26`. The subnet and gateway are unchanged, so `10.10.10.1` stays the trusted proxy, and the overlay pins (`.5`, `.252`, `.253`) stay outside the pool. + +- [#1005](https://github.com/cameri/nostream/pull/1005) [`b185f35`](https://github.com/cameri/nostream/commit/b185f35cd4de4ba20f60ac0b2dc5876e6c710343) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - ci(deploy): retry the website deploy on a transient Cloudflare failure + + Run 40103130697 failed at `Deploy the preview worker` with Cloudflare API error + `code: 10013` after a build that had already succeeded, and the next push deployed + the same commit successfully — a transient upstream failure, not a defect in the + site or the workflow. + + Both website deploys now run through `.github/scripts/deploy-with-retry.sh`, which + wraps the deploy step (not the build) in three attempts with a 10 s then 30 s + backoff, a per-attempt timeout, and a step-level `timeout-minutes`. It retries only + `code: 10013`, an HTTP 5xx, a connection reset and an expired attempt timeout; an + authentication failure, a missing environment or a wrangler validation error still + fails on the first attempt. Every retry emits a `::warning::` naming the attempt and + the reason, so the flake rate stays visible instead of disappearing behind a + passing job. + +- [#1000](https://github.com/cameri/nostream/pull/1000) [`dff00ba`](https://github.com/cameri/nostream/commit/dff00baca20dba191858abc1de50b69a743063e7) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Add canonical first-party production relay setup guide at `/docs/setup` and replace outdated third-party guides in `README.md`. + +- [#1003](https://github.com/cameri/nostream/pull/1003) [`80b0a9e`](https://github.com/cameri/nostream/commit/80b0a9e52412ce4f554065fe5a6c6fac62af99b6) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - test(redis): prove the relay keeps publishing after the cache loses its scripts + + Nothing is shipped in this change — it is coverage for [#1002](https://github.com/cameri/nostream/issues/1002). The relay issues `EVALSHA`, and Lua scripts live in the cache's memory, so a Redis restart leaves every script call answering `NOSCRIPT`. The adapter's retry for that was added in [#920](https://github.com/cameri/nostream/issues/920) and had only a stubbed unit test: `evalSha` was mocked, which cannot catch a retry that stops working through the workers that actually call it. + + The new integration scenario publishes, runs `SCRIPT FLUSH` against the same Redis, then publishes twice more. `SCRIPT FLUSH` is the whole of a restart as far as the relay can tell, and it is deterministic — no container to restart and no reconnect to wait for. The scenario configures a message rate limit on purpose, because both limiters return early on an empty list and a scenario without one would pass whether or not the fallback existed. + +## 3.8.0 + +### Minor Changes + +- [#941](https://github.com/cameri/nostream/pull/941) [`42ed39e`](https://github.com/cameri/nostream/commit/42ed39edf61ef6e8091e2fa32cad8e76823bb67d) Thanks [@Ferryx349](https://github.com/Ferryx349)! - feat(admin): moderation tools for pubkey, kind, and IP limits (closes [#634](https://github.com/cameri/nostream/issues/634)) + + Adds admin API and console view to read and update event pubkey/kind lists, rate-limit bypass lists, quick ban/unban actions, and activity stats for top publishers, client IPs, and rate-limit hits. + +### Patch Changes + +- [#968](https://github.com/cameri/nostream/pull/968) [`98bd234`](https://github.com/cameri/nostream/commit/98bd2340af5bfd79deaa83bcd3a145550e269343) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - chore(deps): build and test on Node 24.22.1, which patches the Brotli decoder + + CVE-2026-111497 is an out-of-bounds write in every Brotli decoder up to 1.2.0, + reachable by anything that decodes a `br` stream. Node bundles its own decoder, + so the fix is the Node release: 24.22.1 on the 24 line. `.nvmrc` and both + Dockerfile stages move from floating `node:24-alpine` to a pinned + `node:24.22.1-alpine`. + +- [#945](https://github.com/cameri/nostream/pull/945) [`062ee00`](https://github.com/cameri/nostream/commit/062ee007d150b7c4d0daff5032be3ad71de7773e) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Namespace EWMA rate-limiter keys so they cannot collide with sliding-window keys + + The two strategies store different Redis types under the same key name: a sliding + window keeps a sorted set, EWMA keeps a hash. A cache volume carried over from 2.x, + or an operator switching `limits.rateLimiter.strategy`, therefore left EWMA reading a + sorted set as a hash — a `WRONGTYPE` error on every connection and message until the + old key expired. EWMA now writes under an `ewma:` prefix, which also keeps both + strategies' windows intact if the strategy is switched back. + +- [#965](https://github.com/cameri/nostream/pull/965) [`ee65257`](https://github.com/cameri/nostream/commit/ee652577236a2a2da6499f3a58f98e0ed3e581c4) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(ci): publish legal image tags and keep `latest` off prereleases + + A changesets relay release is tagged `nostream@3.7.0`, and `@` is not legal in a + Docker tag, so the `type=ref,event=tag` rule handed buildx a tag it refused: the + first live run of the Release workflow failed with `invalid tag ...: invalid +reference format` and published no image for the release ([#964](https://github.com/cameri/nostream/issues/964)). + + The tags are now computed by a shell step, so every tag is a value this + repository chose and can check: the bare version, `vX.Y.Z` as well beside it for + a legacy tag, and `latest` only when the release is not a prerelease. Each tag is + validated against Docker's grammar before the build, so an illegal one fails by + name at that step instead of at buildx. + + `docs/CI-MIGRATION.md`, `docs/DEPLOYMENT.md` and `docs/DEPLOY-RUNBOOK.md` no + longer promise `nostream@X.Y.Z` as a pinnable image tag, and the runbook now + names the receiver change that subscribing to `release` events depends on. + +- [#969](https://github.com/cameri/nostream/pull/969) [`8ea15fc`](https://github.com/cameri/nostream/commit/8ea15fce0f65916d6c9c1956eb2a3a77eb121a2b) Thanks [@Ferryx349](https://github.com/Ferryx349)! - feat(website): add the relay peering launch film and home-page section + +- [#967](https://github.com/cameri/nostream/pull/967) [`9c736c8`](https://github.com/cameri/nostream/commit/9c736c83d7ea116cd9492c619c7a2f7a156c20ba) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - ci(release): allow the image build to be dispatched for an existing tag + + A published release is immutable, so when the image build fails while publishing + it, the release cannot be re-published to retry: GitHub refuses the round trip + with `immutable cannot be changed when release is immutable`. That left [#964](https://github.com/cameri/nostream/issues/964)'s + release with no image and no way to build one. + + `release.yml` now accepts `workflow_dispatch` with a `tag` input, and refuses a + tag that is not a relay release, since the event carries no tag of its own. With + no release flags present the prerelease is read from the version, so a dispatch + of `nostream@3.8.0-rc.1` still leaves `latest` where it is. + +## 3.7.0 + +### Minor Changes + +- [#895](https://github.com/cameri/nostream/pull/895) [`a52cbfd`](https://github.com/cameri/nostream/commit/a52cbfd3149b68327f046ecc78e485746ba5cd58) Thanks [@Ferryx349](https://github.com/Ferryx349)! - Add admin Operations area with mirror and worker heartbeats, NIP-05 verification ops, storage counts, and dashboard view. + +- [#920](https://github.com/cameri/nostream/pull/920) [`abd4a1b`](https://github.com/cameri/nostream/commit/abd4a1bf96698b8f829d1d1a3bb86ecc1f405f4d) Thanks [@Ferryx349](https://github.com/Ferryx349)! - Add admin trust and automation: Redis-backed audit log API and UI, signed HTTP webhooks for probe/mirror/SSL events, OpenTelemetry probe and mirror metrics, and redacted diagnostics export. + +- [#849](https://github.com/cameri/nostream/pull/849) [`7e504e1`](https://github.com/cameri/nostream/commit/7e504e1a377822d7cfbb199f143b088c8d9db776) Thanks [@Priyanshubhartistm](https://github.com/Priyanshubhartistm)! - feat(payments): add the subscription data model and plan settings + +- [#909](https://github.com/cameri/nostream/pull/909) [`7b4f627`](https://github.com/cameri/nostream/commit/7b4f6278a1ee500088c9e7b06d4a5af968eec227) Thanks [@Priyanshubhartistm](https://github.com/Priyanshubhartistm)! - feat(payments): add the subscription lifecycle, renewal invoices and plan-based write access + +- [#924](https://github.com/cameri/nostream/pull/924) [`e417437`](https://github.com/cameri/nostream/commit/e41743788e603b3b549dd80ca381f671690679d6) Thanks [@Priyanshubhartistm](https://github.com/Priyanshubhartistm)! - chore: sunset the StaticMirroringWorker + + The static mirroring worker is removed, together with the `mirroring.static` settings and the mirror health table and `/admin/ops/mirrors` route of the admin console. A `mirroring.static` that is still configured is ignored and logs a warning at start-up. To sync events with another relay, use NIP-77 (Negentropy), for example with `strfry sync`. + +### Patch Changes + +- [#943](https://github.com/cameri/nostream/pull/943) [`17157d5`](https://github.com/cameri/nostream/commit/17157d54513ba0424ccae512b5fdd7eb02bafdc5) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(payments): parse the ISO 8601 `time`/`expiry` returned by LNbits >= 1.0 so paid admissions are confirmed and expired invoices are retired + +- [#944](https://github.com/cameri/nostream/pull/944) [`82124b0`](https://github.com/cameri/nostream/commit/82124b02180b3c722e217ed39eb71c324ddec1a9) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(ci): stop building and pushing container images outside a release + + Remove the container publish job from checks.yml so pushes to main no longer publish + images to GHCR. Container image builds and pushes are now restricted exclusively to + release.yml on published releases. Update deployment documentation and reference compose + stacks to target the released image tag (:latest) instead of :main. + +## 3.6.2 + +### Patch Changes + +- [#892](https://github.com/cameri/nostream/pull/892) [`ff53a73`](https://github.com/cameri/nostream/commit/ff53a7384974b405bc2ec7b00075702be3482603) Thanks [@Ferryx349](https://github.com/Ferryx349)! - Fix the operator notifications target type dropdown so the select chevron renders once on the right instead of tiling across the field when `console-input` and `form-select` share the same control. + +## 3.6.1 + +### Patch Changes + +- [#875](https://github.com/cameri/nostream/pull/875) [`59c5ebf`](https://github.com/cameri/nostream/commit/59c5ebfcf1aaf81b4235d59e0724c27d2a98fa14) Thanks [@Ferryx349](https://github.com/Ferryx349)! - Restyle the admin console to match nostream.dev (dark black-and-pink theme, login spark field) and fix Grafana solo-panel embed URLs so in-dashboard charts load reliably. + +## 3.6.0 + +### Minor Changes + +- [#814](https://github.com/cameri/nostream/pull/814) [`d11faf4`](https://github.com/cameri/nostream/commit/d11faf4a077f7871cb4548dc84e758c5df07e752) Thanks [@Ferryx349](https://github.com/Ferryx349)! - Add bounded NIP-66 probe history API and Network Health dashboard timeline + +## 3.5.1 + +### Patch Changes + +- [#838](https://github.com/cameri/nostream/pull/838) [`50ffe33`](https://github.com/cameri/nostream/commit/50ffe335780b8144350bd91fbf455e3b911ba808) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(deps): resolve open dependabot security alerts + + Bumps axios to ^1.20.0, stream-json to ^3.7.0 (with the v3 ESM import-shape fix in the event import service) and js-yaml to 4.3.2, and adds pnpm overrides forcing proxy-addr >= 2.0.8, fast-uri >= 3.1.7, brace-expansion >= 1.1.21 <2 / >= 2.1.7 <3 and js-yaml 3.x >= 3.15.2 <4. The hero-animation package stubs its unfixable transitive ip <= 2.0.1 (werift chain, unused at runtime) with @stdlib/utils-noop. + +## 3.5.0 + +### Minor Changes + +- [#823](https://github.com/cameri/nostream/pull/823) [`82287d4`](https://github.com/cameri/nostream/commit/82287d41bc7228ee0d68961ce535b5e9b52570d4) Thanks [@Ferryx349](https://github.com/Ferryx349)! - feat(website): add Astro marketing site with landing film, docs, changelog and community pages + + Adds `website/site` (nostream.dev) and updates the landing hero animation to sync NIP count and branding from the repo. Docs, changelog, version label and NIP list are read from the repository at build time. + +- [#830](https://github.com/cameri/nostream/pull/830) [`f0bcdcc`](https://github.com/cameri/nostream/commit/f0bcdcc4f53a7fdf5d71ec3013038dce1a779188) Thanks [@Priyanshubhartistm](https://github.com/Priyanshubhartistm)! - feat(nip77): advertise NIP-77 in NIP-11 supported_nips while it is active and expose its settings in the admin console + +- [#818](https://github.com/cameri/nostream/pull/818) [`7409006`](https://github.com/cameri/nostream/commit/7409006e612cbe209517aa24992af6439cc66d5c) Thanks [@Priyanshubhartistm](https://github.com/Priyanshubhartistm)! - feat(nip77): serve NEG-OPEN, NEG-MSG and NEG-CLOSE negentropy sync for the PostgreSQL event store + +## 3.4.1 + +### Patch Changes + +- [#825](https://github.com/cameri/nostream/pull/825) [`48fe9c4`](https://github.com/cameri/nostream/commit/48fe9c46489fb500699675c073a8638da28dfd71) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(settings): validate `info.relay_url` is a usable absolute URL + + `validateSettings()` now rejects a `relay_url` that is not a valid absolute URL, parses with an empty host (scheme-only strings like `localhost:8008`), or uses a protocol other than `ws`/`wss`/`http`/`https`. Previously an invalid value started cleanly and then broke every HTTP request with `500 TypeError: Invalid URL`, including the Admin Console, leaving the bad setting fixable only by editing the file by hand. Found in [#824](https://github.com/cameri/nostream/issues/824). + ## 3.4.0 ### Minor Changes diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..a6cbfe0bb --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +complaints@cameri.dev. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 0754a7082..4d39bbb1d 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -10,6 +10,7 @@ The following environment variables can be set: | RELAY_PORT | Relay's server port | 8008 | | RELAY_PRIVATE_KEY | Relay's private key in hex | (auto-generated) | | WORKER_COUNT | Number of workers override | No. of available CPUs | +| LOG_FORMAT | Set to `json` to replace the startup banner with one `relay starting` record, so every line the relay writes parses as JSON. | (banner) | | DB_URI | PostgreSQL URI (overrides DB_HOST, DB_PORT, etc.) | | | DB_HOST | PostgresSQL Hostname | | | DB_PORT | PostgreSQL Port | 5432 | @@ -166,7 +167,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta | limits.event.content[].maxLength | Maximum length of `content`. Defaults to 1 MB. Disabled when set to zero. | | limits.event.createdAt.maxNegativeDelta | Maximum number of seconds an event's `created_at` can be in the past. Defaults to zero. Disabled when set to zero. | | limits.event.createdAt.maxPositiveDelta | Maximum number of seconds an event's `created_at` can be in the future. Defaults to 900 (15 minutes). Disabled when set to zero. | -| limits.event.eventId.minLeadingZeroBits | Leading zero bits required on every incoming event for proof of work. Defaults to zero. Disabled when set to zero. Ignored on the client path while `limits.event.pow.enabled` is true (mirrored events from `static-mirroring-worker.ts` still enforce this static value). | +| limits.event.eventId.minLeadingZeroBits | Leading zero bits required on every incoming event for proof of work. Defaults to zero. Disabled when set to zero. Ignored on the client path while `limits.event.pow.enabled` is true. | | limits.event.kind.blacklist | List of event kinds to always reject. Leave empty to allow any. | | limits.event.kind.whitelist | List of event kinds to always allow. Leave empty to allow any. | | limits.event.pow.ceilingBits | Maximum adaptive PoW difficulty, reached at approximately 2x `targetEventsPerSecond` and beyond. | @@ -228,12 +229,18 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip66.dnsCacheTtlSeconds | DNS cache TTL in seconds for repeated probe lookups of the same hostname. Defaults to 300. | | nip66.enabled | Enable NIP-66 relay monitoring. When true, starts a `relay-monitor` cluster worker that probes targets on an interval and stores the latest snapshot in Redis. Defaults to false. | | nip66.probeIntervalSeconds | Seconds between scheduled relay probe runs. Defaults to 3600. | -| nip66.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. Addresses from `mirroring.static[]` are probed for the admin snapshot but are not published as kind `30166` discovery events. | +| nip66.targets | Public WebSocket URLs to probe (for example `wss://relay.example.com`). When empty, defaults to `info.relay_url`. | | nip66.geohash | Optional NIP-52 geohash describing this monitor's network vantage. Published on kind `10166` monitor announcements when set. | +| nip66.historyRetentionRuns | Maximum probe run snapshots kept for the admin Network Health history API and timeline. Defaults to 168. | +| nip66.historyRetentionHours | Optional maximum age in hours for retained probe history. When unset, history is trimmed by run count only. | | nip66.timeouts.dnsMs | DNS probe timeout in milliseconds. Defaults to 10000. | | nip66.timeouts.nip11Ms | NIP-11 fetch timeout in milliseconds. Defaults to 10000. | | nip66.timeouts.tlsMs | TLS probe timeout in milliseconds. Defaults to 10000. | | nip66.timeouts.wsRttMs | WebSocket open RTT probe timeout in milliseconds. Defaults to 10000. | +| nip77.enabled | Enable NIP-77 (Negentropy) set-reconciliation sync, so clients and other relays can find which events differ without downloading them all. Defaults to false. Only answers when `eventStore.backend` is `postgres`; other backends bring their own negentropy implementation. While active, the relay lists `77` in the NIP-11 `supported_nips`; it is left out when this is off or another backend is used. All `nip77.*` settings can be edited in the admin console and the CLI settings menu. When `nip42.restrictedReads` is enabled, reconciliation would list the ids of restricted events, so a `NEG-OPEN` filter that could match one (it names a restricted kind or no kinds at all) is only accepted when scoped by `authors` or `#p` to pubkeys the client has authenticated as, and is re-checked on every `NEG-MSG`; otherwise the session is closed with `NEG-ERR auth-required:`. Each open session holds the ids of every event matching its filter in memory, so size the limits below for your hardware. | +| nip77.maxEventsPerSession | Most events one session may cover. A `NEG-OPEN` whose filter matches more is rejected with `NEG-ERR blocked:` (clients should split the sync into `since`/`until` windows). The filter's `limit` is ignored, since reconciliation needs the whole set. Defaults to 100000. | +| nip77.maxSessionsPerConnection | Concurrent negentropy sessions one websocket connection may hold; opening an id that is already open replaces it. Further `NEG-OPEN`s are rejected with `NEG-ERR blocked:`. Defaults to 3. | +| nip77.sessionIdleTimeoutMs | A session that receives no `NEG-MSG` for this many milliseconds is closed with `NEG-ERR closed:`. Defaults to 60000. | | paymentProcessors.lnbits.baseURL | Base URL of your Lnbits instance. | | paymentProcessors.lnbits.callbackBaseURL | Public-facing Nostream's Lnbits Callback URL. (e.g. https://relay.your-domain.com/callbacks/lnbits) | | paymentProcessors.lnurl.invoiceURL | [LUD-06 Pay Request](https://github.com/lnurl/luds/blob/luds/06.md) provider URL. (e.g. https://getalby.com/lnurlp/your-username) | @@ -246,6 +253,14 @@ The settings below are listed in alphabetical order by name. Please keep this ta | payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. | | payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. | | payments.processor | Either `zebedee`, `lnbits`, `lnurl`. | +| payments.subscriptionPlans | Recurring membership plans. Leave empty to keep the one-time admission fee. With plans and `payments.enabled`, `POST /invoices` takes a `planId` instead of a fee schedule, a payment of at least the plan price starts or extends a period (from the period end while access is still running, otherwise from now), and writes need an active subscription or grace period. Admitted users with no subscription keep access. About three days before a period ends the relay creates a renewal invoice and sends it to the subscriber in a kind 402 event, replacing it if it expires unpaid; it moves the subscription through grace to expired once the period and grace pass without payment. Users still have to pay renewal invoices themselves, since Lightning cannot be charged automatically. The invoice page lets users pick a plan. Clients see the plans in the NIP-11 document under `fees.subscription` (`id`, `amount` in msats, `unit`, `period` in seconds, and `description` when set), and with plans `limitation.restricted_writes` is true. `GET /subscriptions/status?pubkey=` returns a pubkey's `planId`, `status`, `active`, `currentPeriodEnd` and `accessEndsAt` (without a subscription `active` is false and the other fields are null), counted against `limits.invoice.rateLimits`; it answers 404 while plans are off. | +| payments.subscriptionPlans[].id | Unique plan name, for example `basic`. `grandfathered` is reserved: users admitted before plans existed are moved onto it and keep access without a paid period. | +| payments.subscriptionPlans[].amount | Price of one period in msats. Must be unique across plans. A payment below it does not start or extend a period. | +| payments.subscriptionPlans[].intervalSeconds | Length of one paid period, in seconds. | +| payments.subscriptionPlans[].graceSeconds | How long access lasts after a period ends without a renewal, in seconds. `0` ends access straight away. | +| payments.subscriptionPlans[].description | Optional text shown to users. | +| payments.subscriptionPlans[].limits.rateLimits | Optional event rate limits for subscribers on this plan, in the same format as `limits.event.rateLimits`. They replace the global limits for a subscriber while their subscription gives access, grace period included; an empty list means no limit. Everyone else, and subscribers whose subscription ended, keep the global limits. The plan is cached with admission, so a change of plan takes effect within five minutes. | +| payments.subscriptionPlans[].limits.retentionMaxDays | Optional event retention in days for subscribers on this plan. It replaces `limits.event.retention.maxDays` for a subscriber while their subscription gives access, so it only applies while that is set. Once the subscription ends, the global window purges their events at the next pass. The kind and pubkey whitelists apply as before. | | workers.count | Number of workers to spin up to handle incoming connections. | | | Spin workers as many CPUs are available when set to zero. Defaults to zero. | | limits.message.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. | @@ -253,11 +268,6 @@ The settings below are listed in alphabetical order by name. Please keep this ta | limits.admissionCheck.rateLimits[].rate | Maximum number of admission checks during period. | | limits.admissionCheck.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. | | limits.rateLimiter.strategy | Rate limiting strategy. Either `ewma` or `sliding_window`. Defaults to `ewma`. When using `ewma`, the `period` field in each rate limit serves as the half-life for the exponential decay function. Note: when switching from `sliding_window` to `ewma`, consider increasing `rate` values slightly as EWMA penalizes bursty behavior more aggressively. | -| mirroring.static[].address | Address of mirrored relay. (e.g. ws://100.100.100.100:8008) | -| mirroring.static[].filters | Subscription filters used to mirror. | -| mirroring.static[].limits.event | Event limit overrides for this mirror. See configurations under limits.event. | -| mirroring.static[].secret | Secret to pass to relays. Nostream relays only. Optional. | -| mirroring.static[].skipAdmissionCheck | Disable the admission fee check for events coming from this mirror. | | network.maxPayloadSize | Maximum number of bytes accepted per WebSocket frame | | network.remoteIpHeader | HTTP header from proxy containing IP address from client. | | network.trustedProxies | Optional allow-list of proxy IPs allowed to set `network.remoteIpHeader`; otherwise socket remote IP is used. | @@ -279,9 +289,61 @@ The settings below are listed in alphabetical order by name. Please keep this ta | payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. | | payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. | | payments.processor | Either `zebedee`, `lnbits`, `lnurl`, `nodeless`, `opennode`, `nwc`. | +| peering.contact | Contact URI (e.g. `mailto:peering@your-domain.com`) advertised to other relays for establishing peering. At least one of `contact` or `endpoint` is required when peering is enabled. | +| peering.enabled | Advertise a `peering` section in the NIP-11 document. This only expresses feeds and terms; it does not accept peers. Defaults to false. | +| peering.endpoint | WebSocket endpoint (`ws://` or `wss://`) peers should connect to, advertised to other relays. At least one of `contact` or `endpoint` is required when peering is enabled. | +| peering.minRequestPowDifficulty | NIP-13 proof-of-work bits required on peering requests, advertised as `peering.min_pow_difficulty`. `0` (default) means none. Applies to peering requests only, never to operational traffic. | +| peering.peers[].enabled | Whether this configured peer is active. Defaults to true. | +| peering.peers[].pubkey | Peer relay's own pubkey (hex or `npub1...`). Peers are identified by this key: a valid NIP-42 AUTH from any other key gets no peer recognition. | +| peering.peers[].relayUrl | WebSocket URL (`ws://` or `wss://`) used when connecting outbound to this peer. Its host must match the peer's advertised `info.relay_url`, because NIP-42 verifies the `relay` tag host. Inbound recognition uses the pubkey, never the URL. | +| peering.scopes[].kinds | Event kinds this feed covers, as kinds or `[min, max]` ranges. Required for every scope. At least one scope is required when peering is enabled. | +| peering.scopes[].since | Inclusive lower bound of the feed's time range as a unix timestamp in seconds. Omit for no lower bound. Mutually exclusive with `windowSeconds`. | +| peering.scopes[].until | Inclusive upper bound of the feed's time range as a unix timestamp in seconds. Omit for a feed that stays current. Mutually exclusive with `windowSeconds`. | +| peering.scopes[].windowSeconds | Rolling window in seconds: the feed covers the most recent `windowSeconds` of events. Mutually exclusive with `since` and `until`. | +| peering.terms[].amount | Fee amount charged per peering period, denominated in `unit`. | +| peering.terms[].period | Peering period in seconds. | +| peering.terms[].unit | Fee unit (for example `msats`). An empty `terms` list means settlement-free peering. | | workers.count | Number of workers to spin up to handle incoming connections. Spin workers as many CPUs are available when set to zero. Defaults to zero. | | wot.enabled | Enables the Web of Trust graph, rooted at `wot.seedPubkey`, built from NIP-02 contact lists. Defaults to false. | | wot.maxDepth | How many hops out from `wot.seedPubkey` the trust graph extends. Direct follows are distance 1. Defaults to 2. | | wot.minimumFollowers | Minimum number of already-trusted accounts that must follow a pubkey before it enters the graph at 2+ hops. Direct follows are always trusted. Defaults to 1. | | wot.refreshIntervalHours | Reserved for a future periodic full rebuild; not yet read by any code (real-time kind-3 ingestion already keeps the graph current). Defaults to 24. | | wot.seedPubkey | The relay owner's pubkey in hex. Root of the trust graph. Required when `wot.enabled` is true. | + +## Peering advertisement (NIP-11) + +When `peering.enabled` is true, the relay information document served with +`Accept: application/nostr+json` includes a `peering` section built from the +settings above: + +```json +{ + "peering": { + "enabled": true, + "contact": "mailto:peering@your-domain.com", + "endpoint": "wss://relay.example.com", + "min_pow_difficulty": 0, + "scopes": [ + { "kinds": [1, 6], "window_seconds": 3600 }, + { "kinds": [3, 10002], "since": 1700000000 } + ], + "terms": [ + { "amount": 1000, "unit": "msats", "period": 2592000 } + ] + } +} +``` + +`terms` uses the NIP-11 fee-object shape (`amount`, `unit`, `period`). An empty +`terms` list is settlement-free peering: the absence of a fee entry is the +signal, not a zero amount. `min_pow_difficulty` is the NIP-13 work a peering +request must carry; `0` means there is no requirement. When peering is +disabled, the section is published as `{ "enabled": false }` and no scopes, +terms, or contact details are exposed. + +## Peering identity + +`peering.peers` is the inbound trust list. A connection is recognized as a peer +only when a NIP-42 AUTH event proves one of those pubkeys; matching a URL or +self-declaring a key is not enough. The relay's own identity, key storage, and +the AUTH exchange are documented in [docs/PEERING.md](docs/PEERING.md). diff --git a/Dockerfile b/Dockerfile index 2a40cbfb3..48e3b6817 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG PNPM_VERSION=10.33.0 -FROM node:24-alpine AS build +FROM node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 AS build ARG PNPM_VERSION @@ -14,7 +14,7 @@ COPY . . RUN pnpm build -FROM node:24-alpine +FROM node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 ARG PNPM_VERSION diff --git a/Dockerfile.railwayapp b/Dockerfile.railwayapp index bba5109a5..f2abb8df9 100644 --- a/Dockerfile.railwayapp +++ b/Dockerfile.railwayapp @@ -1,7 +1,7 @@ ## Author Saransh Sharma @cynsar foundation ARG PNPM_VERSION=10.33.0 -FROM node:24-alpine as build +FROM node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 AS build ARG PNPM_VERSION ARG PORT @@ -30,7 +30,7 @@ COPY . . RUN pnpm build -FROM node:24-alpine +FROM node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 # Install dumb-init for proper signal handling RUN apk add --no-cache dumb-init diff --git a/Dockerfile.test b/Dockerfile.test index 328cdb5b4..87fd12e4c 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,6 +1,6 @@ ARG PNPM_VERSION=10.33.0 -FROM node:24-alpine +FROM node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 ARG PNPM_VERSION diff --git a/README.md b/README.md index 831a79c7b..921c3d8c6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # [nostream](https://github.com/cameri/nostream)

- nostream logo + nostream logo

@@ -33,7 +33,8 @@

This is a [nostr](https://github.com/fiatjaf/nostr) relay, written in -Typescript. +Typescript. Documentation, features and guides live on the project website: +**[nostream.dev](https://nostream.dev)**. This implementation is production-ready. See below for supported features. @@ -41,6 +42,26 @@ The project master repository is available on [GitHub](https://github.com/cameri [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/Xfk5F7?referralCode=Kfv2ly) +## Table of contents + +- [Features](#features) +- [Requirements](#requirements) +- [Quick start (Docker Compose)](#quick-start-docker-compose) + - [Importing events from JSON Lines or JSON Arrays](#importing-events-from-json-lines-or-json-arrays) + - [NIP-43 invite codes](#nip-43-invite-codes) +- [Quick start (Standalone)](#quick-start-standalone) +- [Production Setup Guide](#production-setup-guide) + - [Accepting payments](#accepting-payments) +- [Running as a Service](#running-as-a-service) +- [Troubleshooting](#troubleshooting) +- [Export Events](#export-events) +- [Benchmark Database Queries](#benchmark-database-queries) +- [Relay Maintenance](#relay-maintenance) +- [Configuration](#configuration) +- [Development & Contributing](#development--contributing) +- [Dev Channel](#dev-channel) +- [License](#license) + ## Features NIPs and MIPs with a relay-specific implementation are listed here. @@ -60,14 +81,19 @@ NIPs and MIPs with a relay-specific implementation are listed here. - [x] NIP-20: Command Results - [x] NIP-22: Event `created_at` Limits - [x] NIP-25: Reactions -- [ ] NIP-26: Delegated Event Signing (REMOVED) +- [ ] NIP-26: Delegated Event Signing (removed upstream; not implemented) - [x] NIP-28: Public Chat - [x] NIP-33: Parameterized Replaceable Events - [x] NIP-40: Expiration Timestamp +- [x] NIP-42: Authentication of clients to relays +- [x] NIP-43: Invite codes - [x] NIP-44: Encrypted Payloads (Versioned) - [x] NIP-45: Event Counts +- [x] NIP-50: Search capability - [x] NIP-62: Request to Vanish - [x] NIP-65: Relay List Metadata +- [x] NIP-77: Negentropy Synchronization + - [x] Marmot Protocol [MIPs 00–03](https://github.com/marmot-protocol/marmot): E2EE group messaging over Nostr — kinds 443, 444, 445, 10051 and 30443, with `supported_mips` advertised in the NIP-11 relay information document ## Requirements @@ -89,120 +115,6 @@ NIPs and MIPs with a relay-specific implementation are listed here. WARNING: Docker distributions from Snap, Brew or Debian repositories are NOT SUPPORTED and will result in errors. Install Docker from their [official guide](https://docs.docker.com/engine/install/) ONLY. -## Full Guide - -- [Set up a Paid Nostr relay with Nostream and ZEBEDEE](https://docs.zebedee.io/docs/guides/nostr-relay) by [André Neves](https://primal.net/andre) (CTO & Co-Founder at [ZEBEDEE](https://zebedee.io/)) -- [Set up a Nostr relay in under 5 minutes](https://andreneves.xyz/p/set-up-a-nostr-relay-server-in-under) by [André Neves](https://twitter.com/andreneves) (CTO & Co-Founder at [ZEBEDEE](https://zebedee.io/)) - -### Accepting Payments - -1. Before you begin - - Complete one of the Quick Start guides in this document - - Create a `.env` file - - On `.nostr/settings.yaml` file make the following changes: - - Set `payments.enabled` to `true` - - Set `payments.feeSchedules.admission.enabled` to `true` - - Set `limits.event.pubkey.minBalance` to the minimum balance in msats required to accept events (i.e. `1000000` to require a balance of `1000` sats) - - Choose one of the following payment processors: `zebedee`, `nodeless`, `opennode`, `lnbits`, `lnurl`, `nwc` - -2. [ZEBEDEE](https://zebedee.io) - - Complete the step "Before you begin" - - [Sign up for a ZEBEDEE Developer Dashboard account](https://dashboard.zebedee.io/signup), create a new LIVE Project, and get that Project's API Key - - Set `ZEBEDEE_API_KEY` environment variable with the API Key above on your `.env` file - - ``` - ZEBEDEE_API_KEY={YOUR_ZEBEDEE_API_KEY_HERE} - ``` - - - Follow the required steps for all payments processors - - On `.nostr/settings.yaml` file make the following changes: - - `payments.processor` to `zebedee` - - `paymentsProcessors.zebedee.callbackBaseURL` to match your Nostream URL (e.g. `https://{YOUR_DOMAIN_HERE}/callbacks/zebedee`) - - Restart Nostream (`nostream stop` followed by `nostream start`) - - Read the in-depth guide for more information: [Set Up a Paid Nostr Relay with ZEBEDEE API](https://docs.zebedee.io/docs/guides/nostr-relay) - -3. [Nodeless](https://nodeless.io/?ref=587f477f-ba1c-4bd3-8986-8302c98f6731) - - Complete the step "Before you begin" - - [Sign up](https://nodeless.io/?ref=587f477f-ba1c-4bd3-8986-8302c98f6731) for a new account, create a new store and take note of the store ID - - Go to Profile > API Tokens and generate a new key and take note of it - - Create a store webhook with your Nodeless callback URL (e.g. `https://{YOUR_DOMAIN_HERE}/callbacks/nodeless`) and make sure to enable all of the events. Grab the generated store webhook secret - - Set `NODELESS_API_KEY` and `NODELESS_WEBHOOK_SECRET` environment variables with generated API key and webhook secret, respectively - - ``` - NODELESS_API_KEY={YOUR_NODELESS_API_KEY} - NODELESS_WEBHOOK_SECRET={YOUR_NODELESS_WEBHOOK_SECRET} - ``` - - - On your `.nostr/settings.yaml` file make the following changes: - - Set `payments.processor` to `nodeless` - - Set `paymentsProcessors.nodeless.storeId` to your store ID - - Restart Nostream (`nostream stop` followed by `nostream start`) - -4. [OpenNode](https://www.opennode.com/) - - Complete the step "Before you begin" - - Sign up for a new account and get verified - - Go to Developers > Integrations and setup two-factor authentication - - Create a new API Key with Invoices permission - - Set `OPENNODE_API_KEY` environment variable on your `.env` file - - ``` - OPENNODE_API_KEY={YOUR_OPENNODE_API_KEY} - ``` - - - On your `.nostr/settings.yaml` file make the following changes: - - Set `payments.processor` to `opennode` - - Restart Nostream (`nostream stop` followed by `nostream start`) - -5. [LNBITS](https://lnbits.com/) - - Complete the step "Before you begin" - - Create a new wallet on you public LNbits instance - - [Demo](https://legend.lnbits.com/) server must not be used for production - - Your instance must be accessible from the internet and have a valid SSL/TLS certificate - - Get wallet Invoice/read key (in Api docs section of your wallet) - - set `LNBITS_API_KEY` environment variable with the Invoice/read key Key above on your `.env` file - - ``` - LNBITS_API_KEY={YOUR_LNBITS_API_KEY_HERE} - ``` - - On your `.nostr/settings.yaml` file make the following changes: - - Set `payments.processor` to `lnbits` - - set `lnbits.baseURL` to your LNbits instance URL (e.g. `https://{YOUR_LNBITS_DOMAIN_HERE}/`) - - Set `paymentsProcessors.lnbits.callbackBaseURL` to match your Nostream URL (e.g. `https://{YOUR_DOMAIN_HERE}/callbacks/lnbits`) - - Restart Nostream (`nostream stop` followed by `nostream start`) - -6. [Alby](https://getalby.com/) or any LNURL Provider with [LNURL-verify](https://github.com/lnurl/luds/issues/182) support - - Complete the step "Before you begin" - - [Create a new account](https://getalby.com/user/new) if you don't have an LNURL - - On your `.nostr/settings.yaml` file make the following changes: - - Set `payments.processor` to `lnurl` - - Set `lnurl.invoiceURL` to your LNURL (e.g. `https://getalby.com/lnurlp/your-username`) - - Restart Nostream (`nostream stop` followed by `nostream start`) - -7. Nostr Wallet Connect (NIP-47 / NWC) - - Complete the step "Before you begin" - - Create an app connection in your NWC-compatible wallet and copy the generated NWC URL - - Set `NWC_URL` environment variable on your `.env` file - - ``` - NWC_URL={NOSTR_WALLET_CONNECT_URL} - ``` - - - On your `.nostr/settings.yaml` file make the following changes: - - Set `payments.processor` to `nwc` - - Restart Nostream (`./scripts/stop` followed by `./scripts/start`) - -8. Ensure payments are required for your public key - - Visit https://{YOUR-DOMAIN}/ - - You should be presented with a form requesting an admission fee to be paid - - Fill out the form and take the necessary steps to pay the invoice - - Wait until the screen indicates that payment was received - - Add your relay URL to your favorite Nostr client (wss://{YOUR-DOMAIN}) and wait for it to connect - - Send a couple notes to test - - Go to https://websocketking.com/ and connect to your relay (wss://{YOUR_DOMAIN}) - - Convert your npub to hexadecimal using a [Key Converter](https://damus.io/key/) - - Send the following JSON message: `["REQ", "payment-test", {"authors":["your-pubkey-in-hexadecimal"]}]` - - You should get back the few notes you sent earlier - ## Quick Start (Docker Compose) For full command reference and interactive mode documentation, see [CLI.md](CLI.md). @@ -212,59 +124,59 @@ Non-interactive CLI usage conventions: - exit `2` on usage errors (invalid command/options) Optional global installation from a source checkout: - ``` - pnpm add -g . - nostream --help - ``` +``` +pnpm add -g . +nostream --help +``` Install Docker following the [official guide](https://docs.docker.com/engine/install/). You may have to uninstall Docker if you installed it using a different guide. Clone repository and enter directory: - ``` - git clone git@github.com:Cameri/nostream.git - cd nostream - ``` +``` +git clone git@github.com:Cameri/nostream.git +cd nostream +``` Generate a secret with: `openssl rand -hex 128` Copy the output and paste it into an `.env` file: - ``` - SECRET=aaabbbccc...dddeeefff - # Secret shortened for brevity - ``` +``` +SECRET=aaabbbccc...dddeeefff +# Secret shortened for brevity +``` Start: - ``` - nostream start - ``` +``` +nostream start +``` or - ``` - nostream start --tor - ``` +``` +nostream start --tor +``` or - ``` - nostream start --i2p - ``` +``` +nostream start --i2p +``` or - ``` - RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com nostream start --nginx - ``` +``` +RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com nostream start --nginx +``` Stop the server with: - ``` - nostream stop - ``` +``` +nostream stop +``` Print the Tor hostname: - ``` - nostream info --tor-hostname - ``` +``` +nostream info --tor-hostname +``` Print I2P hostname(s): - ``` - nostream info --i2p-hostname - ``` +``` +nostream info --i2p-hostname +``` The old shell wrapper scripts are no longer shipped in `scripts/`. Use the unified `nostream` CLI directly instead: @@ -291,30 +203,30 @@ Compressed `.jsonl` files are also supported and decompressed on-the-fly: - `.jsonl.xz` (XZ) Basic import: - ``` - nostream import ./events.jsonl - ``` +``` +nostream import ./events.jsonl +``` Equivalent alias form: - ``` - nostream import --file ./events.jsonl - ``` +``` +nostream import --file ./events.jsonl +``` Import from a JSON array file (compatible with `nostream export --format json`): - ``` - nostream import --file ./events.json - ``` +``` +nostream import --file ./events.json +``` Import a compressed backup: - ``` - nostream import ./events.jsonl.gz - nostream import ./events.jsonl.xz - ``` +``` +nostream import ./events.jsonl.gz +nostream import ./events.jsonl.xz +``` Set a custom batch size (default: `1000`): - ``` - nostream import ./events.jsonl --batch-size 500 - ``` +``` +nostream import ./events.jsonl --batch-size 500 +``` The importer: @@ -330,10 +242,10 @@ The importer: Kind 28934 join requests are implemented. Mint a code and share it out of band: - ``` - nostream invite create --json - docker compose exec nostream node src/cli/index.js invite create - ``` +``` +nostream invite create --json +docker compose exec nostream node src/cli/index.js invite create +``` Users can also request a code themselves: the relay answers a `REQ` for kind 28935 by minting one on the fly. Off by default — set `nip43.allowInviteRequests` to enable it. @@ -341,199 +253,260 @@ minting one on the fly. Off by default — set `nip43.allowInviteRequests` to en See [CLI.md](CLI.md) for `--uses` / `--expires-in` and Docker vs local Postgres, and [CONFIGURATION.md](CONFIGURATION.md) for the invite request settings. -### Running as a Service +## Quick Start (Standalone) -By default this server will run continuously until you stop it with Ctrl+C or until the system restarts. +Set the following environment variables: -You can [install as a systemd service](https://www.swissrouting.com/nostr.html#installing-as-a-service) if you want the server to run again automatically whenever the system is restarted. For example: +``` +DB_URI="postgresql://postgres:postgres@localhost:5432/nostr_ts_relay_test" +DB_USER=postgres +``` + or +``` +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=nostr_ts_relay +DB_USER=postgres +DB_PASSWORD=postgres +``` - ``` - $ nano /etc/systemd/system/nostream.service +``` +REDIS_URI="redis://default:nostr_ts_relay@localhost:6379" - # Note: replace "User=..." with your username, and - # "/home/nostr/nostream" with the directory where you cloned the repo. +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_USER=default +REDIS_PASSWORD=nostr_ts_relay +``` - [Unit] - Description=Nostr TS Relay - After=network.target - StartLimitIntervalSec=0 +Generate a long random secret and set SECRET: +You may want to use `openssl rand -hex 128` to generate a secret. - [Service] - Type=simple - Restart=always - RestartSec=5 - User=nostr - WorkingDirectory=/home/nostr/nostream - ExecStart=/usr/bin/env bash -lc 'cd /home/nostr/nostream && nostream start' - ExecStop=/usr/bin/env bash -lc 'cd /home/nostr/nostream && nostream stop' +``` +SECRET=aaabbbccc...dddeeefff +# Secret shortened for brevity +``` - [Install] - WantedBy=multi-user.target - ``` +### Initializing the database -And then: +Create `nostr_ts_relay` database: - ``` - systemctl enable nostream - systemctl start nostream - ``` +``` +$ psql -h $DB_HOST -p $DB_PORT -U $DB_USER -W +postgres=# create database nostr_ts_relay; +postgres=# quit +``` -The logs can be viewed with: +Start Redis and use `redis-cli` to set the default password and verify: +``` +$ redis-cli +127.0.0.1:6379> CONFIG SET requirepass "nostr_ts_relay" +OK +127.0.0.1:6379> AUTH nostr_ts_relay +Ok +``` - ``` - journalctl -u nostream - ``` +Clone repository and enter directory: +``` +git clone git@github.com:Cameri/nostream.git +cd nostream +``` -## Troubleshooting +Install dependencies: -### Linux: Docker DNS resolution failures (`EAI_AGAIN`) +``` +corepack enable +pnpm install +``` -On some Linux environments (especially rolling-release distros or setups using -`systemd-resolved`), `docker compose` builds can fail with DNS errors such as: +Run migrations (at least once and after pulling new changes): -- `getaddrinfo EAI_AGAIN registry.npmjs.org` -- `Temporary failure in name resolution` +``` +pnpm db:migrate +``` -To fix this, configure Docker daemon DNS in `/etc/docker/daemon.json`. +Create .nostr folder inside nostream project folder and copy over the settings file: -1. Create or update `/etc/docker/daemon.json`: +``` +mkdir .nostr +cp resources/default-settings.yaml .nostr/settings.yaml +``` - ``` - sudo mkdir -p /etc/docker - sudo nano /etc/docker/daemon.json - ``` +To start in development mode: - Add or update the file with: +``` +pnpm dev +``` - ``` - { - "dns": ["1.1.1.1", "8.8.8.8"] - } - ``` +Or, start in production mode: - If this file already exists, merge the `dns` key into the existing JSON - instead of replacing the entire file. +``` +pnpm start +``` - If your environment does not allow public resolvers, replace `1.1.1.1` and - `8.8.8.8` with DNS servers approved by your network. +To clean up the build, coverage and test reports run: -2. Restart Docker: +``` +pnpm clean +``` +## Production Setup Guide - ``` - sudo systemctl restart docker - ``` +For a step-by-step walkthrough covering host preparation, Docker Compose deployment, reverse proxy configuration with TLS, and verification, see the official [Production Setup Guide](https://nostream.dev/docs/setup). -3. Verify DNS works inside containers: +Prefer to explore all reference guides? See the [documentation index](https://nostream.dev/docs). - ``` - docker run --rm busybox nslookup registry.npmjs.org - ``` +### Accepting Payments -4. Retry starting nostream: +1. Before you begin + - Complete the [Production Setup Guide](https://nostream.dev/docs/setup) or one of the Quick Start guides in this document + - Create a `.env` file + - On `.nostr/settings.yaml` file make the following changes: + - Set `payments.enabled` to `true` + - Set `payments.feeSchedules.admission.enabled` to `true` + - Set `limits.event.pubkey.minBalance` to the minimum balance in msats required to accept events (i.e. `1000000` to require a balance of `1000` sats) + - Choose one of the following payment processors: `zebedee`, `nodeless`, `opennode`, `lnbits`, `lnurl`, `nwc` - ``` - nostream start - ``` +2. [ZEBEDEE](https://zebedee.io) + - Complete the step "Before you begin" + - [Sign up for a ZEBEDEE Developer Dashboard account](https://dashboard.zebedee.io/signup), create a new LIVE Project, and get that Project's API Key + - Set `ZEBEDEE_API_KEY` environment variable with the API Key above on your `.env` file -Note: avoid `127.0.0.53` in Docker DNS settings because it points to the host's -local resolver stub and is often unreachable from containers. +``` + ZEBEDEE_API_KEY={YOUR_ZEBEDEE_API_KEY_HERE} +``` -## Quick Start (Standalone) + - Follow the required steps for all payments processors + - On `.nostr/settings.yaml` file make the following changes: + - `payments.processor` to `zebedee` + - `paymentsProcessors.zebedee.callbackBaseURL` to match your Nostream URL (e.g. `https://{YOUR_DOMAIN_HERE}/callbacks/zebedee`) + - Restart Nostream (`nostream stop` followed by `nostream start`) + - Read the in-depth guide for more information: [Set Up a Paid Nostr Relay with ZEBEDEE API](https://docs.zbdpay.com/payments/nostr/nostr-relay) -Set the following environment variables: +3. [Nodeless](https://nodeless.io/?ref=587f477f-ba1c-4bd3-8986-8302c98f6731) + - Complete the step "Before you begin" + - [Sign up](https://nodeless.io/?ref=587f477f-ba1c-4bd3-8986-8302c98f6731) for a new account, create a new store and take note of the store ID + - Go to Profile > API Tokens and generate a new key and take note of it + - Create a store webhook with your Nodeless callback URL (e.g. `https://{YOUR_DOMAIN_HERE}/callbacks/nodeless`) and make sure to enable all of the events. Grab the generated store webhook secret + - Set `NODELESS_API_KEY` and `NODELESS_WEBHOOK_SECRET` environment variables with generated API key and webhook secret, respectively - ``` - DB_URI="postgresql://postgres:postgres@localhost:5432/nostr_ts_relay_test" - DB_USER=postgres - ``` - or - ``` - DB_HOST=localhost - DB_PORT=5432 - DB_NAME=nostr_ts_relay - DB_USER=postgres - DB_PASSWORD=postgres - ``` - - ``` - REDIS_URI="redis://default:nostr_ts_relay@localhost:6379" - - REDIS_HOST=localhost - REDIS_PORT=6379 - REDIS_USER=default - REDIS_PASSWORD=nostr_ts_relay - ``` +``` + NODELESS_API_KEY={YOUR_NODELESS_API_KEY} + NODELESS_WEBHOOK_SECRET={YOUR_NODELESS_WEBHOOK_SECRET} +``` -Generate a long random secret and set SECRET: -You may want to use `openssl rand -hex 128` to generate a secret. + - On your `.nostr/settings.yaml` file make the following changes: + - Set `payments.processor` to `nodeless` + - Set `paymentsProcessors.nodeless.storeId` to your store ID + - Restart Nostream (`nostream stop` followed by `nostream start`) - ``` - SECRET=aaabbbccc...dddeeefff - # Secret shortened for brevity - ``` +4. [OpenNode](https://www.opennode.com/) + - Complete the step "Before you begin" + - Sign up for a new account and get verified + - Go to Developers > Integrations and setup two-factor authentication + - Create a new API Key with Invoices permission + - Set `OPENNODE_API_KEY` environment variable on your `.env` file -### Initializing the database + ``` + OPENNODE_API_KEY={YOUR_OPENNODE_API_KEY} + ``` -Create `nostr_ts_relay` database: + - On your `.nostr/settings.yaml` file make the following changes: + - Set `payments.processor` to `opennode` + - Restart Nostream (`nostream stop` followed by `nostream start`) - ``` - $ psql -h $DB_HOST -p $DB_PORT -U $DB_USER -W - postgres=# create database nostr_ts_relay; - postgres=# quit - ``` +5. [LNBITS](https://lnbits.com/) + - Complete the step "Before you begin" + - Create a new wallet on you public LNbits instance + - [Demo](https://legend.lnbits.com/) server must not be used for production + - Your instance must be accessible from the internet and have a valid SSL/TLS certificate + - Get wallet Invoice/read key (in Api docs section of your wallet) + - set `LNBITS_API_KEY` environment variable with the Invoice/read key Key above on your `.env` file -Start Redis and use `redis-cli` to set the default password and verify: - ``` - $ redis-cli - 127.0.0.1:6379> CONFIG SET requirepass "nostr_ts_relay" - OK - 127.0.0.1:6379> AUTH nostr_ts_relay - Ok - ``` + ``` + LNBITS_API_KEY={YOUR_LNBITS_API_KEY_HERE} + ``` + - On your `.nostr/settings.yaml` file make the following changes: + - Set `payments.processor` to `lnbits` + - set `lnbits.baseURL` to your LNbits instance URL (e.g. `https://{YOUR_LNBITS_DOMAIN_HERE}/`) + - Set `paymentsProcessors.lnbits.callbackBaseURL` to match your Nostream URL (e.g. `https://{YOUR_DOMAIN_HERE}/callbacks/lnbits`) + - Restart Nostream (`nostream stop` followed by `nostream start`) -Clone repository and enter directory: - ``` - git clone git@github.com:Cameri/nostream.git - cd nostream - ``` +6. [Alby](https://getalby.com/) or any LNURL Provider with [LNURL-verify](https://github.com/lnurl/luds/issues/182) support + - Complete the step "Before you begin" + - [Create a new account](https://getalby.com/user/new) if you don't have an LNURL + - On your `.nostr/settings.yaml` file make the following changes: + - Set `payments.processor` to `lnurl` + - Set `lnurl.invoiceURL` to your LNURL (e.g. `https://getalby.com/lnurlp/your-username`) + - Restart Nostream (`nostream stop` followed by `nostream start`) -Install dependencies: +7. Nostr Wallet Connect (NIP-47 / NWC) + - Complete the step "Before you begin" + - Create an app connection in your NWC-compatible wallet and copy the generated NWC URL + - Set `NWC_URL` environment variable on your `.env` file - ``` - corepack enable - pnpm install - ``` + ``` + NWC_URL={NOSTR_WALLET_CONNECT_URL} + ``` -Run migrations (at least once and after pulling new changes): + - On your `.nostr/settings.yaml` file make the following changes: + - Set `payments.processor` to `nwc` + - Restart Nostream (`nostream stop` followed by `nostream start`) - ``` - pnpm db:migrate - ``` +8. Ensure payments are required for your public key + - Visit https://{YOUR-DOMAIN}/ + - You should be presented with a form requesting an admission fee to be paid + - Fill out the form and take the necessary steps to pay the invoice + - Wait until the screen indicates that payment was received + - Add your relay URL to your favorite Nostr client (wss://{YOUR-DOMAIN}) and wait for it to connect + - Send a couple notes to test + - Go to https://websocketking.com/ and connect to your relay (wss://{YOUR_DOMAIN}) + - Convert your npub to hexadecimal using a [Key Converter](https://damus.io/key/) + - Send the following JSON message: `["REQ", "payment-test", {"authors":["your-pubkey-in-hexadecimal"]}]` + - You should get back the few notes you sent earlier -Create .nostr folder inside nostream project folder and copy over the settings file: +## Running as a Service - ``` - mkdir .nostr - cp resources/default-settings.yaml .nostr/settings.yaml - ``` +By default this server will run continuously until you stop it with Ctrl+C or until the system restarts. -To start in development mode: +You can [install as a systemd service](https://www.swissrouting.com/nostr.html#installing-as-a-service) if you want the server to run again automatically whenever the system is restarted. For example: + +``` +$ nano /etc/systemd/system/nostream.service + +# Note: replace "User=..." with your username, and +# "/home/nostr/nostream" with the directory where you cloned the repo. + +[Unit] +Description=Nostr TS Relay +After=network.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +Restart=always +RestartSec=5 +User=nostr +WorkingDirectory=/home/nostr/nostream +ExecStart=/usr/bin/env bash -lc 'cd /home/nostr/nostream && nostream start' +ExecStop=/usr/bin/env bash -lc 'cd /home/nostr/nostream && nostream stop' + +[Install] +WantedBy=multi-user.target +``` - ``` - pnpm dev - ``` +And then: -Or, start in production mode: +``` +systemctl enable nostream +systemctl start nostream +``` - ``` - pnpm start - ``` +The logs can be viewed with: -To clean up the build, coverage and test reports run: +``` +journalctl -u nostream +``` - ``` - pnpm clean - ``` ## Development & Contributing For development environment setup, testing, linting, load testing, and contribution guidelines @@ -605,38 +578,92 @@ corresponding data from the derived `event_tags` table when present. Dry run (no deletion): - ``` - nostream dev db:clean --all --dry-run - ``` +``` +nostream dev db:clean --all --dry-run +``` Full wipe: - ``` - nostream dev db:clean --all --force - ``` +``` +nostream dev db:clean --all --force +``` Delete events older than N days: - ``` - nostream dev db:clean --older-than=30 --force - ``` +``` +nostream dev db:clean --older-than=30 --force +``` Delete only selected kinds: - ``` - nostream dev db:clean --kinds=1,7,4 --force - ``` +``` +nostream dev db:clean --kinds=1,7,4 --force +``` Delete only selected kinds older than N days: - ``` - nostream dev db:clean --older-than=30 --kinds=1,7,4 --force - ``` +``` +nostream dev db:clean --older-than=30 --kinds=1,7,4 --force +``` By default, the command asks for explicit confirmation (`Type 'DELETE' to confirm`). Use `--force` to skip the prompt. +## Troubleshooting + +### Linux: Docker DNS resolution failures (`EAI_AGAIN`) + +On some Linux environments (especially rolling-release distros or setups using +`systemd-resolved`), `docker compose` builds can fail with DNS errors such as: + +- `getaddrinfo EAI_AGAIN registry.npmjs.org` +- `Temporary failure in name resolution` + +To fix this, configure Docker daemon DNS in `/etc/docker/daemon.json`. + +1. Create or update `/etc/docker/daemon.json`: + +``` +sudo mkdir -p /etc/docker +sudo nano /etc/docker/daemon.json +``` + + Add or update the file with: + +``` +{ + "dns": ["1.1.1.1", "8.8.8.8"] +} +``` + + If this file already exists, merge the `dns` key into the existing JSON + instead of replacing the entire file. + + If your environment does not allow public resolvers, replace `1.1.1.1` and + `8.8.8.8` with DNS servers approved by your network. + +2. Restart Docker: + +``` +sudo systemctl restart docker +``` + +3. Verify DNS works inside containers: + +``` +docker run --rm busybox nslookup registry.npmjs.org +``` + +4. Retry starting nostream: + +``` +nostream start +``` + +Note: avoid `127.0.0.53` in Docker DNS settings because it points to the host's +local resolver stub and is often unreachable from containers. + ## Configuration You can change the default folder by setting the `NOSTR_CONFIG_DIR` environment variable to a different path. diff --git a/SECURITY.md b/SECURITY.md index fd72ba0e9..51201c86b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,18 +2,25 @@ ## Supported Versions -Use this section to tell people about which versions of your project are -currently being supported with security updates. +Security fixes land on **nostream 3.5 and newer** only. The current release line is 3.6.x (3.6.1 at the time of writing). -| Version | Supported | -| --------- | ------------------ | -| > 1.11.x | :white_check_mark: | -| < 1.9.0 | :x: | +| Version | Supported | +| --------------- | ------------------ | +| 3.5.x and newer | :white_check_mark: | +| 3.0.x – 3.4.x | :x: | +| 2.x | :x: | +| 1.x | :x: | + +Unsupported lines receive no advisories, backports or patches. If you run one of them, upgrade to a supported version before reporting. + +The Rust rewrite (`rewrite/main`) does not ship a release yet. It is covered by this policy as it ships. ## Reporting a Vulnerability -Use this section to tell people how to report a vulnerability. +Please do not open a public issue for a security problem. Report it privately through GitHub's private vulnerability reporting: + + + +Include the affected version, how your deployment is configured, and steps to reproduce. -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. +You can expect a first response within 7 days. After that, we will keep you updated on the progress of the report. If the report is accepted, we will work on a fix and publish a security advisory once a patched release is available. If it is declined, we will explain why. diff --git a/commitlint.config.js b/commitlint.config.js index 0d6c58661..c1b037c15 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,6 +1,14 @@ module.exports = { extends: ['@commitlint/config-conventional'], parserPreset: 'conventional-changelog-conventionalcommits', + // One commit on `main` cannot follow the convention: a code-of-conduct file was + // added through the GitHub web UI on 2026-10-17, so its subject has no type. It + // is published, so it cannot be rewritten, and the commit-lint check lints every + // commit in a merge's range — which means any branch that merges `main` fails on + // a commit it did not author. Exempt that one subject rather than weaken the + // check for the commits a pull request actually writes. (Measured 2026-11-02 over + // `main`..`rewrite/main`: this is the only commit in the range that fails.) + ignores: [(message) => message.split('\n')[0].trim() === 'Create CODE_OF_CONDUCT.md'], rules: { 'body-max-line-length': [0, 'always'], 'header-max-length': [0, 'always'], diff --git a/crates/nostream-config/src/notifications.rs b/crates/nostream-config/src/notifications.rs index 466e9b2ff..e090fba00 100644 --- a/crates/nostream-config/src/notifications.rs +++ b/crates/nostream-config/src/notifications.rs @@ -63,6 +63,14 @@ pub struct OperatorNotificationEventToggles { pub settings_changed: Option, #[serde(rename = "relay.restarted", skip_serializing_if = "Option::is_none")] pub relay_restarted: Option, + // Added on main with the probe/SSL watchers. Modelled so the defaults + // document round-trips and validates; the Rust relay emits the five above. + #[serde(rename = "probe.failed", skip_serializing_if = "Option::is_none")] + pub probe_failed: Option, + #[serde(rename = "probe.recovered", skip_serializing_if = "Option::is_none")] + pub probe_recovered: Option, + #[serde(rename = "ssl.expiring", skip_serializing_if = "Option::is_none")] + pub ssl_expiring: Option, } /// Retry policy (`OperatorNotificationRetrySettings`). @@ -154,6 +162,9 @@ fn merge_events( .or(defaults.admission_invoice_failed), settings_changed: configured.settings_changed.or(defaults.settings_changed), relay_restarted: configured.relay_restarted.or(defaults.relay_restarted), + probe_failed: configured.probe_failed.or(defaults.probe_failed), + probe_recovered: configured.probe_recovered.or(defaults.probe_recovered), + ssl_expiring: configured.ssl_expiring.or(defaults.ssl_expiring), } } diff --git a/crates/nostream-config/src/settings.rs b/crates/nostream-config/src/settings.rs index c551cdba0..58077fca2 100644 --- a/crates/nostream-config/src/settings.rs +++ b/crates/nostream-config/src/settings.rs @@ -440,6 +440,8 @@ pub struct Payments { pub fee_schedules: FeeSchedules, #[serde(skip_serializing_if = "Option::is_none")] pub invoice_expiry_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subscription_plans: Option>, } /// `paymentsProcessors.lnurl` (`src/@types/settings.ts` `LnurlPaymentsProcessor`). @@ -683,6 +685,12 @@ pub struct Nip66Settings { pub dns_cache_ttl_seconds: u64, #[serde(skip_serializing_if = "Option::is_none")] pub geohash: Option, + /// How many probe runs are kept. `nip66.historyRetentionRuns`. + #[serde(skip_serializing_if = "Option::is_none")] + pub history_retention_runs: Option, + /// Optional max age for probe history; 0 disables age-based trimming. + #[serde(skip_serializing_if = "Option::is_none")] + pub history_retention_hours: Option, } /// `nip05` (`src/@types/settings.ts` `Nip05Settings`). @@ -776,7 +784,106 @@ pub struct Nip43Settings { pub invite_request_whitelist: Option>, } -/// `nip56` (`src/@types/settings.ts` `Nip56Settings`). +/// `nip77` (`src/@types/settings.ts` `Nip77Settings`). +/// +/// Modelled here so the defaults document round-trips; the Rust relay does not +/// answer NIP-77 yet, and the guided editor deliberately has no category for it +/// (`deferred_nip77_surface_is_not_modelled`). An implementation reads these +/// fields when it lands — the settings exist because the document has them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Nip77Settings { + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_events_per_session: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_sessions_per_connection: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_idle_timeout_ms: Option, +} + +/// One feed this relay offers peers (`src/@types/settings.ts` `PeeringScope`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PeeringScope { + /// Event kinds, or `[min, max]` ranges. + pub kinds: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub until: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub window_seconds: Option, +} + +/// One peering price, in the NIP-11 fee-object shape (`src/@types/settings.ts` `PeeringTerm`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PeeringTerm { + pub amount: i64, + pub unit: String, + pub period: i64, +} + +/// A configured peer, trusted by pubkey (`src/@types/settings.ts` `PeeringPeer`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PeeringPeer { + pub pubkey: String, + pub relay_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +/// `peering` (`src/@types/settings.ts` `PeeringSettings`). +/// +/// A discovery surface for the NIP-11 document and the trust list inbound AUTH +/// is matched against. Modelled so the defaults document round-trips; the +/// validator's key list comes from that document, so an unmodelled section here +/// would make every peering configuration invalid. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PeeringSettings { + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_request_pow_difficulty: Option, + #[serde(default)] + pub scopes: Vec, + #[serde(default)] + pub terms: Vec, + #[serde(default)] + pub peers: Vec, +} + +/// A recurring membership plan (`src/@types/settings.ts` `SubscriptionPlan`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionPlan { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Price of one period in msats. + pub amount: i64, + pub interval_seconds: i64, + pub grace_seconds: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub limits: Option, +} + +/// What a plan replaces for its subscribers (`src/@types/settings.ts` `SubscriptionPlanLimits`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionPlanLimits { + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limits: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub retention_max_days: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Nip56Settings { @@ -822,5 +929,9 @@ pub struct Settings { #[serde(skip_serializing_if = "Option::is_none")] pub nip66: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub nip77: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub peering: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub wot: Option, } diff --git a/crates/nostream-config/src/validate.rs b/crates/nostream-config/src/validate.rs index 2e603bb0c..bb3539967 100644 --- a/crates/nostream-config/src/validate.rs +++ b/crates/nostream-config/src/validate.rs @@ -28,6 +28,29 @@ pub fn validate_document(candidate: &Value, defaults: &Value) -> Vec { if !truthy_opt(key_path(candidate, &["info", "relay_url"])) { issues.push(Issue::new("info.relay_url", "relay_url is required")); + } else if let Some(Value::String(value)) = key_path(candidate, &["info", "relay_url"]) { + // Order matters and is the capture's: parse, then host, then scheme. A + // string that is not an absolute URL reports once, as unparseable, rather + // than three times. + match Url::parse(value) { + Ok(parsed) => { + if parsed.host_str().is_none_or(str::is_empty) { + issues.push(Issue::new( + "info.relay_url", + "relay_url must include a valid host", + )); + } else if !matches!(parsed.scheme(), "ws" | "wss" | "http" | "https") { + issues.push(Issue::new( + "info.relay_url", + "relay_url must use ws, wss, http, or https protocol", + )); + } + } + Err(_) => issues.push(Issue::new( + "info.relay_url", + "relay_url must be a valid absolute URL", + )), + } } if !truthy_opt(key_path(candidate, &["info", "name"])) { @@ -196,6 +219,50 @@ pub fn validate_document(candidate: &Value, defaults: &Value) -> Vec { ); } + // `nip66` retention, `nip77` caps, subscription plans and peering are modelled by the + // typed settings above but were not validated: the document is shared with the + // TypeScript app, which rejects these documents, so `config validate` blessing them + // would leave one file with two verdicts. + if let Some(nip66) = key(candidate, "nip66") + && let Some(Value::Number(hours)) = key(nip66, "historyRetentionHours") + { + let whole = hours.as_i64().is_some() || hours.as_f64().is_some_and(|f| f.fract() == 0.0); + let value = to_number(&Value::Number(hours.clone())).unwrap_or(-1.0); + if !whole || !(value == 0.0 || value >= 1.0) { + issues.push(Issue::new( + "nip66.historyRetentionHours", + "historyRetentionHours must be 0 (disabled) or at least 1", + )); + } + } + + if let Some(nip77) = key(candidate, "nip77") + && truthy_opt(key(nip77, "enabled")) + { + // Wrong types are the shape walk's to report; this rule is about the value. + for field in [ + "maxEventsPerSession", + "maxSessionsPerConnection", + "sessionIdleTimeoutMs", + ] { + if let Some(Value::Number(number)) = key(nip77, field) { + let value = to_number(&Value::Number(number.clone())).unwrap_or(-1.0); + let whole = number.as_i64().is_some() + || number.as_u64().is_some() + || number.as_f64().is_some_and(|f| f.fract() == 0.0); + if !whole || value <= 0.0 { + issues.push(Issue::new( + format!("nip77.{field}"), + format!("{field} must be a positive integer"), + )); + } + } + } + } + + validate_subscription_plans(candidate, &mut issues); + validate_peering(candidate, &mut issues); + validate_shape(defaults, Some(candidate), &[], &mut issues); issues.extend(validate_admin_notifications(candidate)); @@ -226,16 +293,175 @@ fn validate_rate_limit_periods( )); continue; } - if !to_number_is_positive(key(entry, "period")) { + let period_path = format!("{path_prefix}[{index}].period"); + match key(entry, "period") { + // Three mistakes, three messages, in the capture's order: not a number, + // not a whole number, not at least one. A window of 60 ms is a valid + // count and stays valid; the unit cannot be inferred from the value. + Some(Value::Number(number)) => { + let integral = number.as_i64().is_some() + || number.as_u64().is_some() + || number.as_f64().is_some_and(|float| float.fract() == 0.0); + if !integral { + issues.push(Issue::new( + period_path, + "period must be a whole number of milliseconds", + )); + } else if !to_number_is_positive(Some(&Value::Number(number.clone()))) { + issues.push(Issue::new( + period_path, + "period must be at least 1 millisecond", + )); + } + } + _ => issues.push(Issue::new( + period_path, + "period must be a number of milliseconds", + )), + } + } +} + +/// Walks the defaults document as the schema for the candidate. +/// `payments.subscriptionPlans` — the same rules as `validateSubscriptionPlans`. +/// +/// A plan is selected by the amount paid, so the amount is both validated and required to be +/// unique: two plans at one price cannot be told apart by a payment. +fn validate_subscription_plans(candidate: &Value, issues: &mut Vec) { + let Some(Value::Sequence(plans)) = key_path(candidate, &["payments", "subscriptionPlans"]) + else { + return; + }; + let mut ids: Vec = Vec::new(); + let mut amounts: Vec = Vec::new(); + for (index, plan) in plans.iter().enumerate() { + let prefix = format!("payments.subscriptionPlans[{index}]"); + if !is_plain_object(plan) { + issues.push(Issue::new(prefix, "plan must be an object")); + continue; + } + match key(plan, "id") { + Some(Value::String(id)) if !id.trim().is_empty() => { + if id == "grandfathered" { + issues.push(Issue::new( + format!("{prefix}.id"), + "id \"grandfathered\" is reserved", + )); + } else if ids.contains(id) { + issues.push(Issue::new( + format!("{prefix}.id"), + format!("id \"{id}\" is used by more than one plan"), + )); + } else { + ids.push(id.clone()); + } + } + _ => issues.push(Issue::new(format!("{prefix}.id"), "id is required")), + } + let amount = key(plan, "amount"); + if !to_number_is_positive(amount) { + issues.push(Issue::new( + format!("{prefix}.amount"), + "amount must be a positive integer (msats)", + )); + } else if let Some(value) = amount.and_then(serde_yaml::Value::as_i64) { + let text = value.to_string(); + if amounts.contains(&text) { + issues.push(Issue::new( + format!("{prefix}.amount"), + format!("amount {value} is used by more than one plan"), + )); + } else { + amounts.push(text); + } + } + if !to_number_is_positive(key(plan, "intervalSeconds")) { issues.push(Issue::new( - format!("{path_prefix}[{index}].period"), - "period must be greater than 0", + format!("{prefix}.intervalSeconds"), + "intervalSeconds must be a positive integer", + )); + } + if !to_number_is_positive(key(plan, "graceSeconds")) { + issues.push(Issue::new( + format!("{prefix}.graceSeconds"), + "graceSeconds must be a positive integer", + )); + } + } +} + +/// `peering` — the discovery surface the NIP-11 document publishes. +/// +/// The scopes, terms and peers graphs are not validated here yet; a configuration that is +/// accepted here and rejected by the TypeScript app is the state this whole rule set exists to +/// end, so what is missing is named in the issue that tracks the port. +fn validate_peering(candidate: &Value, issues: &mut Vec) { + let Some(peering) = key(candidate, "peering") else { + return; + }; + if !is_plain_object(peering) { + return; + } + if let Some(Value::Number(bits)) = key(peering, "minRequestPowDifficulty") { + let value = to_number(&Value::Number(bits.clone())).unwrap_or(-1.0); + let whole = bits.as_i64().is_some() || bits.as_f64().is_some_and(|f| f.fract() == 0.0); + if !whole || !(0.0..=256.0).contains(&value) { + issues.push(Issue::new( + "peering.minRequestPowDifficulty", + "must be an integer between 0 and 256", + )); + } + } + if let Some(Value::String(endpoint)) = key(peering, "endpoint") + && !endpoint.trim().is_empty() + { + match Url::parse(endpoint.trim()) { + Ok(parsed) => { + if !matches!(parsed.scheme(), "ws" | "wss") + || parsed.host_str().is_none_or(str::is_empty) + { + issues.push(Issue::new( + "peering.endpoint", + "must be a ws:// or wss:// URL", + )); + } + } + Err(_) => issues.push(Issue::new( + "peering.endpoint", + "must be a valid ws:// or wss:// URL", + )), + } + } + if let Some(Value::String(contact)) = key(peering, "contact") + && !contact.trim().is_empty() + && Url::parse(contact.trim()).is_err() + { + issues.push(Issue::new( + "peering.contact", + "must be a valid URI (for example mailto: or https:)", + )); + } + if truthy_opt(key(peering, "enabled")) { + let scopes = key(peering, "scopes"); + let has_scopes = matches!(scopes, Some(Value::Sequence(entries)) if !entries.is_empty()); + if !has_scopes { + issues.push(Issue::new( + "peering.scopes", + "at least one scope is required when peering is enabled", + )); + } + let has_contact = + matches!(key(peering, "contact"), Some(Value::String(text)) if !text.trim().is_empty()); + let has_endpoint = matches!(key(peering, "endpoint"), Some(Value::String(text)) if !text.trim().is_empty()); + if !has_contact && !has_endpoint { + issues.push(Issue::new( + "peering.contact", + "contact or endpoint is required when peering is enabled", )); } } } -/// Walks the defaults document as the schema for the candidate. fn validate_shape( schema: &Value, candidate: Option<&Value>, diff --git a/crates/nostream-config/tests/capture/gen-ts-issues.mjs b/crates/nostream-config/tests/capture/gen-ts-issues.mjs index fffc37eb7..99755b42f 100644 --- a/crates/nostream-config/tests/capture/gen-ts-issues.mjs +++ b/crates/nostream-config/tests/capture/gen-ts-issues.mjs @@ -118,6 +118,44 @@ const CASES = [ { name: 'missing_relay_url', mutations: [{ op: 'delete', path: 'info.relay_url' }] }, { name: 'bad_strategy', mutations: [{ op: 'set', path: 'limits.rateLimiter.strategy', value: 'random' }] }, { name: 'nip77_present', mutations: [{ op: 'set', path: 'nip77', value: { enabled: true } }] }, + { + name: 'nip77_zero_cap', + mutations: [ + { op: 'set', path: 'nip77.enabled', value: true }, + { op: 'set', path: 'nip77.maxEventsPerSession', value: 0 }, + ], + }, + { + name: 'plan_negative_amount', + mutations: [ + { + op: 'set', + path: 'payments.subscriptionPlans', + value: [{ id: 'basic', amount: -1, intervalSeconds: 86400, graceSeconds: 3600 }], + }, + ], + }, + { + name: 'plan_duplicate_amount', + mutations: [ + { + op: 'set', + path: 'payments.subscriptionPlans', + value: [ + { id: 'basic', amount: 1000000, intervalSeconds: 86400, graceSeconds: 3600 }, + { id: 'pro', amount: 1000000, intervalSeconds: 86400, graceSeconds: 3600 }, + ], + }, + ], + }, + { + name: 'peering_pow_out_of_range', + mutations: [{ op: 'set', path: 'peering.minRequestPowDifficulty', value: 300 }], + }, + { + name: 'peering_enabled_without_scopes', + mutations: [{ op: 'set', path: 'peering.enabled', value: true }], + }, { name: 'relay_url_ftp', mutations: [{ op: 'set', path: 'info.relay_url', value: 'ftp://relay.example.com' }] }, ] diff --git a/crates/nostream-config/tests/fixtures/ts-issues.json b/crates/nostream-config/tests/fixtures/ts-issues.json index fa95e4364..ca256feb3 100644 --- a/crates/nostream-config/tests/fixtures/ts-issues.json +++ b/crates/nostream-config/tests/fixtures/ts-issues.json @@ -1,7 +1,7 @@ { "subject": { "repo": "cameri/nostream", - "commit": "242a3001d77149e4d70151ec03060c4ef44a1547", + "commit": "d286d80eb3ea806d6de27bd2813d5d30d90cd2a5", "files": [ "resources/default-settings.yaml", "src/@types/settings.ts", @@ -10,23 +10,18 @@ "test/unit/utils/settings-config.spec.ts" ], "node": "v24.22.0", - "generated": "2026-10-17", - "command": "cd && node -r ts-node/register/transpile-only /crates/nostream-config/tests/capture/gen-ts-issues.mjs", + "generated": "2026-11-02", + "command": "cd && node -r ts-node/register/transpile-only /crates/nostream-config/tests/capture/gen-ts-issues.mjs", "expected_test_counts": { "source_it": 34, "executed": 41 } }, "deferred": { - "commit": "041fe34e66b9bb433f1ff3383aee7030fdd3bb8f", - "files": [ - "resources/default-settings.yaml", - "src/@types/settings.ts", - "src/utils/settings-config.ts", - "src/utils/settings-guided-schema.ts", - "test/unit/utils/settings-config.spec.ts" - ], - "diff": "+212/-0" + "commit": "d286d80eb3ea806d6de27bd2813d5d30d90cd2a5", + "files": [], + "diff": "0/+0", + "note": "Regenerated after main merged into rewrite/main (2026-11-02). The surfaces that were deferred at 242a3001 - nip77, peering, payments.subscriptionPlans, nip66.historyRetention*, and the probe/ssl notification toggles - arrived with the merge and are modelled in the Rust schema, so nothing is deferred any more." }, "cases": [ { @@ -38,7 +33,11 @@ { "name": "backend_lmdb", "mutations": [ - { "op": "set", "path": "eventStore.backend", "value": "lmdb" } + { + "op": "set", + "path": "eventStore.backend", + "value": "lmdb" + } ], "ts_issues": [ { @@ -46,15 +45,29 @@ "message": "backend must be one of: postgres" } ], - "rust_exempt_paths": ["eventStore.backend"], + "rust_exempt_paths": [ + "eventStore.backend" + ], "exempt_reason": "D7: the rewrite's target backend is LMDB, so the Rust validator accepts lmdb and rejects only values outside {postgres, lmdb}. Path-scoped, not case-scoped." }, { "name": "pow_floor_gt_ceiling", "mutations": [ - { "op": "set", "path": "limits.event.pow.enabled", "value": true }, - { "op": "set", "path": "limits.event.pow.floorBits", "value": 16 }, - { "op": "set", "path": "limits.event.pow.ceilingBits", "value": 8 } + { + "op": "set", + "path": "limits.event.pow.enabled", + "value": true + }, + { + "op": "set", + "path": "limits.event.pow.floorBits", + "value": 16 + }, + { + "op": "set", + "path": "limits.event.pow.ceilingBits", + "value": 8 + } ], "ts_issues": [ { @@ -67,8 +80,16 @@ { "name": "pow_period_zero", "mutations": [ - { "op": "set", "path": "limits.event.pow.enabled", "value": true }, - { "op": "set", "path": "limits.event.pow.periodMs", "value": 0 } + { + "op": "set", + "path": "limits.event.pow.enabled", + "value": true + }, + { + "op": "set", + "path": "limits.event.pow.periodMs", + "value": 0 + } ], "ts_issues": [ { @@ -81,8 +102,16 @@ { "name": "pow_target_zero", "mutations": [ - { "op": "set", "path": "limits.event.pow.enabled", "value": true }, - { "op": "set", "path": "limits.event.pow.targetEventsPerSecond", "value": 0 } + { + "op": "set", + "path": "limits.event.pow.enabled", + "value": true + }, + { + "op": "set", + "path": "limits.event.pow.targetEventsPerSecond", + "value": 0 + } ], "ts_issues": [ { @@ -95,12 +124,16 @@ { "name": "conn_rate_limit_period_zero", "mutations": [ - { "op": "set", "path": "limits.connection.rateLimits[0].period", "value": 0 } + { + "op": "set", + "path": "limits.connection.rateLimits[0].period", + "value": 0 + } ], "ts_issues": [ { "path": "limits.connection.rateLimits[0].period", - "message": "period must be greater than 0" + "message": "period must be at least 1 millisecond" } ], "rust_exempt_paths": [] @@ -111,7 +144,13 @@ { "op": "set", "path": "admin.notifications.targets", - "value": [{ "id": "t1", "type": "telegram", "enabled": true }] + "value": [ + { + "id": "t1", + "type": "telegram", + "enabled": true + } + ] } ], "ts_issues": [ @@ -227,48 +266,95 @@ }, { "name": "unknown_top_level", - "mutations": [{ "op": "set", "path": "bogusSection", "value": {} }], + "mutations": [ + { + "op": "set", + "path": "bogusSection", + "value": {} + } + ], "ts_issues": [ - { "path": "bogusSection", "message": "Unknown setting key" } + { + "path": "bogusSection", + "message": "Unknown setting key" + } ], "rust_exempt_paths": [] }, { "name": "unknown_nested_key", - "mutations": [{ "op": "set", "path": "info.unknownKey", "value": 1 }], + "mutations": [ + { + "op": "set", + "path": "info.unknownKey", + "value": 1 + } + ], "ts_issues": [ - { "path": "info.unknownKey", "message": "Unknown setting key" } + { + "path": "info.unknownKey", + "message": "Unknown setting key" + } ], "rust_exempt_paths": [] }, { "name": "wrong_type_workers_count", "mutations": [ - { "op": "set", "path": "workers.count", "value": "four" } + { + "op": "set", + "path": "workers.count", + "value": "four" + } ], "ts_issues": [ - { "path": "workers.count", "message": "Expected number, got string" } + { + "path": "workers.count", + "message": "Expected number, got string" + } ], "rust_exempt_paths": [] }, { "name": "missing_name", - "mutations": [{ "op": "delete", "path": "info.name" }], - "ts_issues": [{ "path": "info.name", "message": "name is required" }], + "mutations": [ + { + "op": "delete", + "path": "info.name" + } + ], + "ts_issues": [ + { + "path": "info.name", + "message": "name is required" + } + ], "rust_exempt_paths": [] }, { "name": "missing_relay_url", - "mutations": [{ "op": "delete", "path": "info.relay_url" }], + "mutations": [ + { + "op": "delete", + "path": "info.relay_url" + } + ], "ts_issues": [ - { "path": "info.relay_url", "message": "relay_url is required" } + { + "path": "info.relay_url", + "message": "relay_url is required" + } ], "rust_exempt_paths": [] }, { "name": "bad_strategy", "mutations": [ - { "op": "set", "path": "limits.rateLimiter.strategy", "value": "random" } + { + "op": "set", + "path": "limits.rateLimiter.strategy", + "value": "random" + } ], "ts_issues": [ { @@ -281,10 +367,128 @@ { "name": "nip77_present", "mutations": [ - { "op": "set", "path": "nip77", "value": { "enabled": true } } + { + "op": "set", + "path": "nip77", + "value": { + "enabled": true + } + } + ], + "ts_issues": [], + "rust_exempt_paths": [] + }, + { + "name": "nip77_zero_cap", + "mutations": [ + { + "op": "set", + "path": "nip77.enabled", + "value": true + }, + { + "op": "set", + "path": "nip77.maxEventsPerSession", + "value": 0 + } + ], + "ts_issues": [ + { + "path": "nip77.maxEventsPerSession", + "message": "maxEventsPerSession must be a positive integer" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "plan_negative_amount", + "mutations": [ + { + "op": "set", + "path": "payments.subscriptionPlans", + "value": [ + { + "id": "basic", + "amount": -1, + "intervalSeconds": 86400, + "graceSeconds": 3600 + } + ] + } + ], + "ts_issues": [ + { + "path": "payments.subscriptionPlans[0].amount", + "message": "amount must be a positive integer (msats)" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "plan_duplicate_amount", + "mutations": [ + { + "op": "set", + "path": "payments.subscriptionPlans", + "value": [ + { + "id": "basic", + "amount": 1000000, + "intervalSeconds": 86400, + "graceSeconds": 3600 + }, + { + "id": "pro", + "amount": 1000000, + "intervalSeconds": 86400, + "graceSeconds": 3600 + } + ] + } + ], + "ts_issues": [ + { + "path": "payments.subscriptionPlans[1].amount", + "message": "amount 1000000 is used by more than one plan" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "peering_pow_out_of_range", + "mutations": [ + { + "op": "set", + "path": "peering.minRequestPowDifficulty", + "value": 300 + } ], "ts_issues": [ - { "path": "nip77", "message": "Unknown setting key" } + { + "path": "peering.minRequestPowDifficulty", + "message": "must be an integer between 0 and 256" + } + ], + "rust_exempt_paths": [] + }, + { + "name": "peering_enabled_without_scopes", + "mutations": [ + { + "op": "set", + "path": "peering.enabled", + "value": true + } + ], + "ts_issues": [ + { + "path": "peering.scopes", + "message": "at least one scope is required when peering is enabled" + }, + { + "path": "peering.contact", + "message": "contact or endpoint is required when peering is enabled" + } ], "rust_exempt_paths": [] }, @@ -297,7 +501,12 @@ "value": "ftp://relay.example.com" } ], - "ts_issues": [], + "ts_issues": [ + { + "path": "info.relay_url", + "message": "relay_url must use ws, wss, http, or https protocol" + } + ], "rust_exempt_paths": [] } ] diff --git a/crates/nostream-config/tests/ts_parity.rs b/crates/nostream-config/tests/ts_parity.rs index 5d0512667..f11cc5c8c 100644 --- a/crates/nostream-config/tests/ts_parity.rs +++ b/crates/nostream-config/tests/ts_parity.rs @@ -74,8 +74,11 @@ struct IssueDto { message: String, } -const CAPTURE_COMMIT: &str = "242a3001d77149e4d70151ec03060c4ef44a1547"; -const DEFERRED_COMMIT: &str = "041fe34e66b9bb433f1ff3383aee7030fdd3bb8f"; +/// The merge commit that brought `main` into `rewrite/main`; the TS tree it +/// carries is the oracle the cases below were measured from on 2026-11-02. +const CAPTURE_COMMIT: &str = "d286d80eb3ea806d6de27bd2813d5d30d90cd2a5"; +/// Nothing is deferred any more: the surfaces below arrived with the merge. +const DEFERRED_COMMIT: &str = "d286d80eb3ea806d6de27bd2813d5d30d90cd2a5"; fn fixture() -> Fixture { let raw = fs::read_to_string(concat!( @@ -198,8 +201,8 @@ fn fixture_metadata_names_the_capture() { assert_eq!(fixture.subject.expected_test_counts.source_it, 34); assert_eq!(fixture.subject.expected_test_counts.executed, 41); assert_eq!(fixture.deferred.commit, DEFERRED_COMMIT); - assert_eq!(fixture.deferred.diff, "+212/-0"); - assert_eq!(fixture.deferred.files.len(), 5); + assert_eq!(fixture.deferred.diff, "0/+0"); + assert!(fixture.deferred.files.is_empty()); assert!(!fixture.subject.generated.is_empty()); assert!( fixture @@ -254,8 +257,12 @@ fn the_capture_probes_are_present() { assert!(names.contains("defaults")); } +/// The surfaces deferred at `242a3001` arrived with `main` on 2026-11-02 and are modelled now, so +/// the case that used to pin them as absent pins them as accepted instead: the capture and the +/// Rust validator must both take `nip77`, or the parity claim below is about a different document +/// than the one this branch ships. #[test] -fn the_deferred_surfaces_are_pinned_as_absent() { +fn the_ported_surfaces_are_accepted_by_both() { let fixture = fixture(); let case = |name: &str| { fixture @@ -266,18 +273,24 @@ fn the_deferred_surfaces_are_pinned_as_absent() { }; let nip77 = case("nip77_present"); - assert_eq!( - nip77.ts_issues, - vec![IssueDto { - path: "nip77".to_owned(), - message: "Unknown setting key".to_owned() - }] + assert!( + nip77.ts_issues.is_empty(), + "the capture accepts nip77 since the merge: {:?}", + nip77.ts_issues ); assert_eq!(apply_case(nip77, &defaults()), nip77.ts_issues); + // `main` added the protocol rule, so the ftp case is no longer clean: it is the + // evidence that the Rust validator took the same rule on. let ftp = case("relay_url_ftp"); - assert!(ftp.ts_issues.is_empty()); - assert!(apply_case(ftp, &defaults()).is_empty()); + assert_eq!( + ftp.ts_issues, + vec![IssueDto { + path: "info.relay_url".to_owned(), + message: "relay_url must use ws, wss, http, or https protocol".to_owned() + }] + ); + assert_eq!(apply_case(ftp, &defaults()), ftp.ts_issues); } #[test] diff --git a/crates/nostream-relay/src/testing.rs b/crates/nostream-relay/src/testing.rs index a9137f593..1d1108898 100644 --- a/crates/nostream-relay/src/testing.rs +++ b/crates/nostream-relay/src/testing.rs @@ -72,6 +72,8 @@ pub fn settings() -> Settings { nip50: None, nip56: None, nip66: None, + nip77: None, + peering: None, wot: None, } } diff --git a/crates/nostream-relay/tests/handlers.rs b/crates/nostream-relay/tests/handlers.rs index cfaec1369..6de0c50b1 100644 --- a/crates/nostream-relay/tests/handlers.rs +++ b/crates/nostream-relay/tests/handlers.rs @@ -576,6 +576,7 @@ async fn an_admitted_pubkey_publishes_and_a_low_balance_blocks() { publication: None, }, invoice_expiry_seconds: None, + subscription_plans: None, }); settings.limits = Some(event_limits(EventLimits { pubkey: Some(PubkeyLimits { diff --git a/deploy/README.md b/deploy/README.md index d690a6855..95005e09b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -5,8 +5,8 @@ uses a pre-built image from GHCR; migrations and default settings ship inside that image. This guide assumes a Linux host with Docker Engine and the Compose plugin -installed. Container images are published automatically after CI succeeds on pushes to -`main`. See [`docs/DEPLOYMENT.md`](../docs/DEPLOYMENT.md) for the CI/CD flow and +installed. Container images are published automatically when releases are published. +See [`docs/DEPLOYMENT.md`](../docs/DEPLOYMENT.md) for the CI/CD flow and [`docs/DEPLOY-RUNBOOK.md`](../docs/DEPLOY-RUNBOOK.md) for the step-by-step update procedure after a new image lands on the host. @@ -33,7 +33,7 @@ chmod +x deploy/bootstrap.sh ./deploy/bootstrap.sh /opt/nostream ``` -Edit `/opt/nostream/.env`, load `ghcr.io/cameri/nostream:main`, then: +Edit `/opt/nostream/.env`, load `ghcr.io/cameri/nostream:latest`, then: ```bash cd /opt/nostream @@ -46,7 +46,7 @@ from this repository. You only maintain `.env` and optional settings overrides. ## Prerequisites 1. [Docker Engine](https://docs.docker.com/engine/install/) and the Compose plugin -2. `ghcr.io/cameri/nostream:main` loaded on the host (see +2. `ghcr.io/cameri/nostream:latest` loaded on the host (see [Image delivery](#image-delivery-on-restricted-networks) if `docker pull` fails) ## Server layout after bootstrap @@ -65,10 +65,10 @@ from this repository. You only maintain `.env` and optional settings overrides. | Service | Image | Notes | |-------------------|--------------------------------|---------------------------------------------------------| -| nostream | ghcr.io/cameri/nostream:main | `pull_policy: never` when the image is pre-loaded | +| nostream | ghcr.io/cameri/nostream:latest | `pull_policy: never` when the image is pre-loaded | | nostream-db | postgres:15 | | | nostream-cache | redis:7.0.5-alpine3.16 | | -| nostream-migrate | ghcr.io/cameri/nostream:main | one-shot `knex migrate:latest`; same image as the relay | +| nostream-migrate | ghcr.io/cameri/nostream:latest | one-shot `knex migrate:latest`; same image as the relay | The relay listens on `127.0.0.1:8008`. Expose it with a reverse proxy or tunnel (for example Cloudflare Tunnel). diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 764ab609e..5f29b3b07 100755 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -59,7 +59,7 @@ Bootstrap complete: $TARGET Next steps: 1. Edit $TARGET/.env (SECRET, DB_PASSWORD, REDIS_PASSWORD) - 2. Load ghcr.io/cameri/nostream:main on this host + 2. Load ghcr.io/cameri/nostream:latest on this host 3. cd $TARGET && docker compose up -d Optional relay overrides: diff --git a/deploy/docker-compose.haproxy.yml b/deploy/docker-compose.haproxy.yml index c448aaa83..ca7ca668a 100644 --- a/deploy/docker-compose.haproxy.yml +++ b/deploy/docker-compose.haproxy.yml @@ -10,7 +10,7 @@ networks: - subnet: 172.28.0.0/24 x-nostream-relay: &nostream-relay - image: ghcr.io/cameri/nostream:main + image: ghcr.io/cameri/nostream:latest pull_policy: never env_file: .env environment: @@ -125,7 +125,7 @@ services: retries: 10 nostream-migrate: - image: ghcr.io/cameri/nostream:main + image: ghcr.io/cameri/nostream:latest pull_policy: never container_name: nostream-migrate networks: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 16d135a2b..f86501c6e 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -1,6 +1,6 @@ services: nostream: - image: ghcr.io/cameri/nostream:main + image: ghcr.io/cameri/nostream:latest pull_policy: never container_name: nostream env_file: .env @@ -72,7 +72,7 @@ services: retries: 10 nostream-migrate: - image: ghcr.io/cameri/nostream:main + image: ghcr.io/cameri/nostream:latest pull_policy: never container_name: nostream-migrate user: node:node diff --git a/docker-compose.yml b/docker-compose.yml index 2b1c815e4..d640b0232 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -126,7 +126,7 @@ services: retries: 5 nostream-migrate: - image: node:24-alpine + image: node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 container_name: nostream-migrate environment: DB_HOST: nostream-db @@ -189,6 +189,7 @@ services: GF_AUTH_ANONYMOUS_ENABLED: 'true' GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer GF_SECURITY_ALLOW_EMBEDDING: 'true' + GF_SECURITY_COOKIE_SAMESITE: disabled GF_SERVER_ROOT_URL: ${GRAFANA_URL:-http://127.0.0.1:7777} volumes: - ./grafana/provisioning:/etc/grafana/provisioning:ro @@ -209,7 +210,17 @@ networks: ipam: driver: default config: + # Dynamic addresses are confined to .128/26 so they can never land on a + # static one: nostream is .2, and the observability stack takes the first + # three free addresses — which were .2, .3 and .4, taken by nostream, + # nostream-db and nostream-cache. Docker allocates from the bottom of the + # subnet, and otel-collector has no dependency that would order it after + # nostream, so whichever container came up first won the address and the + # other failed with "Address already in use". The overlays pin .5 + # (local), .252 (i2p) and .253 (tor) for the same reason. - subnet: 10.10.10.0/24 + gateway: 10.10.10.1 + ip_range: 10.10.10.128/26 volumes: cache: diff --git a/docs/CI-MIGRATION.md b/docs/CI-MIGRATION.md index c675d6b4f..4b6eb8ffa 100644 --- a/docs/CI-MIGRATION.md +++ b/docs/CI-MIGRATION.md @@ -1,27 +1,30 @@ # CI container publishing migration -Container publishing is now part of `.github/workflows/checks.yml`. +Container publishing is now restricted exclusively to official releases via `.github/workflows/release.yml`. ## What changed -The standalone `publish-container-image.yml` workflow, which listened for a -completed `workflow_run`, was removed. `checks.yml` now contains -`publish-container-image` as its final job. +The `publish-container-image` job was removed from `.github/workflows/checks.yml`. Container images +are no longer built or published on pushes to `main`. ## Why -Keeping publishing in the workflow that performed the checks makes its -dependencies explicit. It avoids the timing and event-context ambiguity of a -separate `workflow_run`, which could be triggered by an external pull-request -run. +Publishing images from unreleased commits on `main` caused the `main` tag (and per-commit SHA tags) +to serve as moving targets of unreleased code, creating drift between published releases and images +pulled in production. Publishing is now aligned strictly with official GitHub releases. ## New behavior -On successful pushes to `main`, CI runs linting, the build check, unit tests, -integration tests, and post-test reporting before publishing the container -image. The publish job cannot run for pull requests or manual workflow runs. +Container images are published only by `.github/workflows/release.yml` when an official relay release +is published. Changesets tags a relay release `nostream@X.Y.Z`, so that prefix is what the workflow +matches; the `vX.Y.Z` form from before the changesets migration is still accepted. Pushes to `main` and pull requests run verification checks without +publishing any container images. ## Action required -None. Developers and deployment users receive the same `main` and per-commit -container image tags automatically after CI succeeds. +Deployments tracking `ghcr.io/cameri/nostream:main` should be updated to track released versions, +such as `ghcr.io/cameri/nostream:latest` or a pinned version tag such as +`ghcr.io/cameri/nostream:3.6.2`. The release tag itself (`nostream@3.6.2`) is not an +image tag: `@` is not legal in a Docker tag, so each release publishes its bare +version and `latest` instead. +See [`deploy/README.md`](../deploy/README.md) and [`docs/DEPLOY-RUNBOOK.md`](DEPLOY-RUNBOOK.md). diff --git a/docs/DEPLOY-RUNBOOK.md b/docs/DEPLOY-RUNBOOK.md index 204769509..28c5d101f 100644 --- a/docs/DEPLOY-RUNBOOK.md +++ b/docs/DEPLOY-RUNBOOK.md @@ -1,7 +1,7 @@ # Deploy runbook (sandbox / production relay) Step-by-step procedure for updating a running nostream host after a new -`ghcr.io/cameri/nostream:main` image is available. +`ghcr.io/cameri/nostream:latest` (or release tag) image is available. | Doc | Purpose | |-----|---------| @@ -11,16 +11,46 @@ Step-by-step procedure for updating a running nostream host after a new ## Image delivery vs relay recreate ```text -GitHub push to main - → CI publishes ghcr.io/cameri/nostream:main - → (optional) webhook pull stack loads the image on the host +GitHub Release published (tag v*) + → CI publishes ghcr.io/cameri/nostream:latest (and the version tag) + → the release webhook pulls :latest onto the host (see below) → operator runs this runbook (migrate + recreate relay(s)) → Cloudflare Tunnel / reverse proxy keeps pointing at 127.0.0.1:8008 ``` +Nothing publishes an image on a push to `main` any more, so a host that still +pulls `:main` will never see a new image. The on-host pull stack must track the +release instead: + +1. `NOSTREAM_TAG` in `/opt/nostream/webhook/.env` set to `latest`. +2. The GitHub webhook subscribed to **releases** as well as **push**. +3. The receiver changed to accept a `release` event. Subscribing alone does + nothing: the receiver ignores every event that is not a push to the watched + branch, so step 2 delivers a delivery it then discards. It must treat a + `release` event whose tag is a relay release as "pull `NOSTREAM_TAG`". + +The receiver is not part of this repository, so step 3 is a change to whatever +deployment owns it. Until all three are done, deployments are manual: pull the +tag by hand and continue at step 2 below. + The webhook pull stack **loads images only**. It does **not** run migrations or recreate relay containers. This runbook closes that gap. +The release event reaches the webhook at the same moment the image build starts, +so the pull fires before the tag exists. The puller retries on a growing backoff +and re-queues a message it exhausts, but **there is no signal that says the pull +landed**. Before you recreate, confirm the image is on the host: + +```bash +docker images --filter=reference='ghcr.io/cameri/nostream' --format '{{.Tag}} {{.ID}}' +# expect the tag the release carried, and a digest that is not the previous one +``` + +A recreate with a stale `latest` starts the previous release from a brand-new +build: the migrate step runs the old schema, nothing fails, and the deploy looks +successful. When in doubt, `docker pull ghcr.io/cameri/nostream:latest` and re-run +the check above. + ## Choose your stack | Stack | Compose file | Update script | Downtime | @@ -43,7 +73,7 @@ are documented under - New relay image on the host (`docker pull` or `docker load`) - Shell access (`cd /opt/nostream`) - Before loading a new image or recreating containers, record the **running** - relay image (not `docker image inspect :main` after pull — that tag may already + relay image (not `docker image inspect :latest` after pull — that tag may already point at the new image) ```bash @@ -84,7 +114,7 @@ Existing `.env` and `.nostr/settings.yaml` are preserved. ### 2. Load the new image (skip if webhook pull already did) ```bash -docker pull ghcr.io/cameri/nostream:main +docker pull ghcr.io/cameri/nostream:latest ``` On hosts that cannot reach GHCR over IPv4, use `docker save` / `docker load`. See @@ -169,10 +199,10 @@ HAProxy stack: stop the relay you just replaced; leave the healthy peer running. ```bash PREVIOUS_IMAGE="$(cat /tmp/nostream-pre-deploy-image-id)" -docker tag "$PREVIOUS_IMAGE" ghcr.io/cameri/nostream:main +docker tag "$PREVIOUS_IMAGE" ghcr.io/cameri/nostream:latest ``` -Or `docker load -i /path/to/nostream-main-backup.tar.gz`. +Or `docker load -i /path/to/nostream-latest-backup.tar.gz`. ### 3. Recreate on the old image (skip migrations) @@ -188,7 +218,7 @@ SKIP_MIGRATE=1 /opt/nostream/recreate-relay.sh /opt/nostream ``` HAProxy: run migrate only when moving **forward** on a new image. For rollback, -retag the old image to `:main`, then recreate relays **without** migrations. +retag the old image to `:latest`, then recreate relays **without** migrations. `rolling-relay-recreate.sh` will **not** replace a running relay unless its peer is up and `/readyz` healthy. If step 1 left one backend stopped, running the diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7d7648fab..2e205b14a 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,19 +1,29 @@ # Deployment and container publishing The `ghcr.io/cameri/nostream` container image is published automatically by the -CI Checks workflow after a successful push to `main`. The workflow publishes -both the `main` tag and a `sha-` tag. +Release workflow (`release.yml`) whenever an official GitHub release is published. +For release `nostream@3.6.2` the workflow publishes two tags: the bare version +(`3.6.2`, which is what a host pins) and `latest`. A prerelease publishes only its +version, so it cannot move `latest` onto a test build. The release tag itself +(`nostream@3.6.2`) is never an image tag — `@` is not legal in a Docker tag — and +the `vX.Y.Z` form used before the changesets migration also publishes `vX.Y.Z` +beside its bare version. -Publishing is the final stage of the CI dependency chain: +Publishing is triggered only on published relay releases — the changesets tag `nostream@X.Y.Z`, or the +`vX.Y.Z` form used before the changesets migration. A `nostream-website@X.Y.Z` release publishes no +image: ```text -changes → lint and build-check → unit and integration tests → post-tests → publish-container-image +release: published (nostream@X.Y.Z) + → build-and-push-image + → ghcr.io/cameri/nostream:{X.Y.Z, latest} ``` -The publish job runs only for `refs/heads/main` and only when `post-tests` -succeeds. Pull requests and manual workflow runs do not publish images. +CI Checks (`checks.yml`) does not build or publish container images on push to +`main` or pull requests, ensuring that published images only correspond to +released versions. -Deployment hosts can pull the `main` image as described in -[`deploy/README.md`](../deploy/README.md). After the image is on the host, follow +Deployment hosts can pull the released image (`latest` or a specific release tag) +as described in [`deploy/README.md`](../deploy/README.md). After the image is on the host, follow [`docs/DEPLOY-RUNBOOK.md`](DEPLOY-RUNBOOK.md) to migrate and recreate the relay. There is no separate manual image publishing step. diff --git a/docs/PEERING.md b/docs/PEERING.md new file mode 100644 index 000000000..375b7c702 --- /dev/null +++ b/docs/PEERING.md @@ -0,0 +1,90 @@ +# Peering identity + +Relay peering lets one relay serve feeds to another. Before any feed exists, each +side must be able to prove which relay it is: a URL is an address, not an +identity. Nostream uses NIP-42 AUTH signed with the relay's own keypair for that +proof, and a configured pubkey list for trust. + +This phase covers identity only. Feed treatment, sync, on-demand requests, +payments, and the admin console build on it. + +## Relay keypair + +The relay already has a signing key: + +- `RELAY_PRIVATE_KEY` (hex), when set, wins. +- Otherwise the key is derived from `SECRET`. +- `SECRET` and `RELAY_PRIVATE_KEY` are environment variables, documented in + [CONFIGURATION.md](../CONFIGURATION.md#environment-variables). The private key + is never written to `settings.yaml` and never sent to another relay. + +`info.self` is optional. When set, it is the pubkey advertised as `self` in the +NIP-11 document and must match the key the relay actually signs with. When unset +or unparseable, NIP-11 advertises the derived signing pubkey instead. Run: + +```bash +nostream info +``` + +to print the relay's derived `self` pubkey. Back up `SECRET` (or +`RELAY_PRIVATE_KEY`) like any other credential; losing it changes the relay's +identity and invalidates peers that trust the old pubkey. + +## Configuring peers + +Peers are configured by pubkey, with the URL used to reach them outbound: + +```yaml +peering: + enabled: true + # ... this relay's own advertisement (contact, endpoint, scopes, terms) ... + peers: + - pubkey: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + relayUrl: wss://peer.example.com + enabled: true + - pubkey: npub1... + relayUrl: wss://archive.example.com +``` + +`pubkey` accepts hex or `npub1...`. `enabled` defaults to true. Duplicate pubkeys +are rejected by settings validation, including the same key written once as hex +and once as an `npub`. `relayUrl` is also the value used for the NIP-42 `relay` +tag, so its host must match the peer's advertised `info.relay_url`; connect to a +relay through a hostname it does not advertise and AUTH will be rejected. + +`peering.enabled` controls the NIP-11 advertisement. `peering.peers` is the +trust list and is independent of it: a relay can keep configured peers while its +advertisement is turned off. + +## Inbound: recognizing a peer + +1. The relay sends `["AUTH", ""]` when a WebSocket connects. +2. The peer sends `["AUTH", ]` with `relay` and `challenge` + tags, signed by its relay key. +3. `AuthMessageHandler` verifies the event id, signature, timestamp window, + challenge, and relay-URL domain match. +4. If verification succeeds, the authenticated pubkey is added to the + connection's NIP-42 session. +5. If that pubkey is an enabled `peering.peers` entry, the connection is marked + as a peer. The peer pubkey is attached to the ephemeral connection state. + +Peer status is cleared when the NIP-42 session expires or the socket closes. +Any other pubkey — including a valid signature from an unconfigured relay — is +authenticated as an ordinary client and receives no peer recognition. + +## Outbound: authenticating to a peer + +`authenticateToPeeringRelay` connects to a peer, waits for its AUTH challenge, +builds the same kind 22242 event with this relay's keypair, sends it, and +resolves only after the peer answers `OK true`. The returned socket is open and +authenticated for the caller to use for a feed or negentropy session. This +proves our identity to the peer; it does not verify the peer's identity in +return. + +Only the public half of the keypair is ever visible on the wire. Inbound AUTH is +verified by `AuthMessageHandler`: event id, signature, timestamp, challenge, and +relay host. Outbound AUTH proves this relay's identity to the peer; it does not +authenticate the remote relay, because NIP-42 is a client-to-relay proof. The +remote relay's identity is established separately by the configured +`peering.peers` pubkey (and, in later phases, by discovery and owner +attestation). diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 000000000..5e1c221c8 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,576 @@ +# Production Relay Setup Guide + +> **Verified against:** nostream v3.6.2 (Ubuntu 24.04 LTS, Docker Engine 27+, Docker Compose v2) +> **Maintenance:** Maintained in-tree and reviewed whenever changes land in `deploy/` or compose configurations. +> +> The steps below pull `:latest`, which may be newer than the version above. Pin a tag in both `docker-compose.yml` and the `docker pull` command (step 4) if you need a known version. + +This is the canonical first-party guide for deploying and operating a production [nostream](https://github.com/cameri/nostream) Nostr relay. It covers host preparation, the automated deployment bootstrap, reverse proxy configuration with automatic TLS, verification, and ongoing maintenance. + +--- + +## What You Need + +Before you begin, ensure you have: + +1. **A Linux host:** A VPS or dedicated server (Ubuntu 24.04 LTS or Debian 12 recommended) with at least: + - 2 vCPUs + - 4 GB RAM — the database configuration this repository ships is tuned for a ~16 GB host, so on 4 or 8 GB you have to size it down in step 3 before starting the stack + - 20+ GB SSD disk space (PostgreSQL event store grows over time) + - A public IPv4 address (and optional IPv6) +2. **A domain name:** A domain or subdomain you control (e.g. `relay.example.com`). + - Create a DNS `A` record pointing `relay.example.com` to your server's IPv4 address. + - If using IPv6, create an `AAAA` record pointing to your server's IPv6 address. +3. **Open firewall ports:** + - Port `80` (HTTP, for ACME certificate issuance and redirect) + - Port `443` (HTTPS / WSS, for secure client connections) + - Port `22` (SSH, management) +4. **A few command-line tools** used later in this guide. `git` fetches the deployment files; `curl` and `openssl` generate your secrets and probe the relay; `jq` pretty-prints the JSON responses: + ```bash + sudo apt-get install -y git curl openssl jq + ``` + Step 9's optional `npx nak` check needs Node.js 18 or newer (`sudo apt-get install -y nodejs npm`), installed only if you want that check; nothing else in this guide uses it. + +--- + +## Common Traps to Avoid + +If you have read older community guides, be aware of the following anti-patterns: + +- **Do NOT expose port 8008 publicly:** The relay binds to `127.0.0.1:8008` by default. Only your local reverse proxy (Caddy or Nginx) should connect to it. Exposing port 8008 directly bypasses TLS and proxy connection limits. +- **Do NOT copy `migrations/` or `knexfile.js` to the host:** Database migrations are baked inside the container image and run automatically through a one-shot container (`nostream-migrate`) during startup. +- **Do NOT copy a full `settings.yaml` to the host:** The relay automatically loads defaults from `resources/default-settings.yaml` inside the image. You only need to supply the specific keys you wish to override. Creating a full copy prevents you from automatically receiving new settings defaults added in future releases. +- **Do NOT install Docker via Snap:** Snap Docker packages apply sandbox restrictions that can interfere with volume mounts and loopback socket networking. Use Docker's official apt/yum repositories. + +--- + +## 1. Install Docker Engine and Compose + +Install Docker Engine and the Compose plugin from the official Docker repository. The repository path differs by distribution — Debian and Ubuntu use separate trees, and pointing a Debian host at the Ubuntu tree makes `apt-get update` fail with a 404. + +```bash +# Remove any conflicting packages +for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do + sudo apt-get remove -y $pkg || true +done + +# Add Docker's official GPG key. The distro is detected from /etc/os-release so this works on +# both Debian and Ubuntu; download.docker.com/linux/ only exists for the matching OS. +source /etc/os-release +case "${ID}" in + debian|ubuntu) DOCKER_DISTRO="${ID}" ;; + *) + echo "Unsupported distribution '${ID}'. Install Docker Engine manually; see https://docs.docker.com/engine/install/" >&2 + exit 1 + ;; +esac + +sudo apt-get update +sudo apt-get install -y ca-certificates curl gnupg +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL "https://download.docker.com/linux/${DOCKER_DISTRO}/gpg" | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg + +# Add the repository to Apt sources +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${DOCKER_DISTRO} \ + ${VERSION_CODENAME} stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Install Docker packages +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +# Verify installation +docker --version +docker compose version +``` + +--- + +## 2. Get the Deployment Files + +Clone the repository to retrieve the deployment scripts and release-managed configuration files: + +```bash +git clone https://github.com/cameri/nostream.git ~/nostream +cd ~/nostream +``` + +--- + +## 3. Bootstrap the Deploy Root + +Run the bootstrap script to create the server layout at `/opt/nostream`: + +```bash +chmod +x deploy/bootstrap.sh +sudo ./deploy/bootstrap.sh /opt/nostream +``` + +This creates the production directory structure: + +```text +/opt/nostream/ +├── docker-compose.yml # Copied from deploy/docker-compose.prod.yml +├── postgresql.conf # Performance-tuned database config +├── .env # Environment secrets (mode 600) +└── .nostr/ + ├── data/ # PostgreSQL data directory (created at start) + └── db-logs/ # PostgreSQL log directory +``` + +### Size the database for your host + +The `postgresql.conf` that bootstrap installs is `postgresql.conf` from the repository root, tuned for a large host: `shared_buffers = 4GB`, `maintenance_work_mem = 1GB` and `effective_cache_size = 12GB`. Those are startup allocations, so on the 4 GB minimum host this guide recommends, PostgreSQL alone reserves the whole machine and the relay, Redis and the OS are left to the kernel's OOM killer. + +On anything smaller than about 16 GB, lower the three values before you start the stack. These are safe for the stated minimum: + +```bash +# 4 GB host. Use 2GB / 512MB / 6GB on an 8 GB host. +sudo sed -i \ + -e 's/^shared_buffers = .*/shared_buffers = 1GB/' \ + -e 's/^maintenance_work_mem = .*/maintenance_work_mem = 256MB/' \ + -e 's/^effective_cache_size = .*/effective_cache_size = 3GB/' \ + /opt/nostream/postgresql.conf +``` + +The rule of thumb behind those numbers: `shared_buffers` at 25% of RAM, `maintenance_work_mem` at RAM/16 up to 1 GB, and `effective_cache_size` at 50–75% of RAM. `postgresql.conf` is a release-managed file, so an update can overwrite your edits — the update section below says how to reapply them and how to make a running database pick them up. + +If you raise the host size instead, run `free -g` and compare: the shipped values want a 16 GB machine, not 8. + +--- + +## 4. Choose Your Relay Image + +The production compose stack uses pre-built images from GitHub Container Registry (GHCR): + +- `ghcr.io/cameri/nostream:latest` — Tracks the latest official release. +- `ghcr.io/cameri/nostream:vX.Y.Z` — Pins a specific release version (recommended for strict immutability, e.g. `ghcr.io/cameri/nostream:v3.6.2`). + +Pull the image onto the server: + +```bash +sudo docker pull ghcr.io/cameri/nostream:latest +``` + +**Note on `pull_policy`.** `docker-compose.yml` sets `pull_policy: never` on relay services so that Docker Compose does not attempt to pull during startup or flapping network conditions. You explicitly pull images when deploying or upgrading. + +That also means **the tag in `docker-compose.yml` is the tag that runs.** Pulling `ghcr.io/cameri/nostream:v3.6.2` while the compose file still says `:latest` deploys `:latest`, not the version you pulled. To pin, set the same tag in both places: + +```bash +sudo sed -i 's|ghcr.io/cameri/nostream:.*|ghcr.io/cameri/nostream:v3.6.2|' /opt/nostream/docker-compose.yml +sudo docker pull ghcr.io/cameri/nostream:v3.6.2 +``` + +--- + +## 5. Configure Environment Variables + +Edit `/opt/nostream/.env` with your editor of choice: + +```bash +sudo nano /opt/nostream/.env +``` + +Generate secure secrets for the placeholders: + +```bash +# Generate a secret for the SECRET variable: +openssl rand -hex 128 + +# Generate passwords for DB_PASSWORD and REDIS_PASSWORD: +openssl rand -hex 32 +``` + +Update the values in `/opt/nostream/.env`: + +```ini +SECRET= + +DB_HOST=nostream-db +DB_PORT=5432 +DB_USER=nostr_ts_relay +DB_PASSWORD= +DB_NAME=nostr_ts_relay + +REDIS_HOST=nostream-cache +REDIS_PORT=6379 +REDIS_USER=default +REDIS_PASSWORD= + +RELAY_PORT=8008 +NOSTR_CONFIG_DIR=/home/node/.nostr + +DB_MIN_POOL_SIZE=16 +DB_MAX_POOL_SIZE=64 +DB_ACQUIRE_CONNECTION_TIMEOUT=60000 +WORKER_COUNT=2 + +RELAY_BROADCAST_FANOUT=false +RELAY_BROADCAST_STREAM_KEY=nostream:relay:broadcast +``` + +*(See [`CONFIGURATION.md`](../CONFIGURATION.md) for full descriptions of all environment variables.)* + +--- + +## 6. Configure Relay Settings + +Both the proxy settings below and any metadata overrides live in one file. Create it from the shipped example: + +```bash +sudo cp ~/nostream/deploy/settings.yaml.example /opt/nostream/.nostr/settings.yaml +sudo chown 1000:1000 /opt/nostream/.nostr/settings.yaml +sudo chmod 600 /opt/nostream/.nostr/settings.yaml +``` + +### Proxy trust (required) + +The relay binds to `127.0.0.1` and **cannot be exposed without a reverse proxy** (step 8), so this applies to every deployment. By default it trusts only loopback and leaves `network.remoteIpHeader` unset, so every request arrives carrying the proxy's address — or, for a published port, the Docker gateway that forwards it — and **all clients share one rate-limit bucket** — one busy client can rate-limit everyone else. + +`deploy/settings.yaml.example` has no `network` section, so add one. Copy the default `trustedProxies` list **in full** and append to it — arrays are replaced wholesale by the settings merge, not appended, so a shortened list deletes addresses the image ships: + +```yaml +network: + # Which header the proxy sets. Both proxies in step 8 set X-Forwarded-For by default. + remoteIpHeader: x-forwarded-for + trustedProxies: + - "127.0.0.1" + - "::ffff:127.0.0.1" + - "::1" + - "10.10.10.1" + - "::ffff:10.10.10.1" + # Append your proxy's address if it is not one of the above. +``` + +The list above is the image default. Entries `10.10.10.x` come from the development `docker-compose.yml`, which pins its own subnet; the production compose publishes the relay on `127.0.0.1:8008` from its own bridge network (`nostream_default`), whose gateway no default entry covers. + +Which address the relay sees depends on how your proxy reaches it, and matching is exact — no CIDR, no ranges — so work it out rather than guessing: + +- **Proxy installed on the host** (the Caddy and Nginx setups below) connects to `127.0.0.1:8008`, but the relay does not see loopback: Docker forwards the published port, so the source is typically the compose network's gateway address, which is not in the default list. The debug line below reports the address the relay actually resolved. Read it and add it: + ```bash + sudo docker network inspect nostream_default --format '{{(index .IPAM.Config 0).Gateway}}' + ``` +- **Proxy in its own container** reaches the relay over the Docker network, so the source is the proxy container's own address. Read it, then add it: + +```bash +sudo docker inspect --format '{{range $net, $cfg := .NetworkSettings.Networks}}{{$net}}={{$cfg.IPAddress}} {{end}}' +``` + +```yaml + trustedProxies: + - "127.0.0.1" + # ... keep the other defaults ... + - "172.18.0.5" # <-- your proxy container +``` + +The relay logs the address it resolved for each websocket client, but that line is written at `debug` level and the default is `info`, so it does not appear by default. Set `LOG_LEVEL=debug` in `/opt/nostream/.env`, restart, connect two different clients, then: + +```bash +sudo docker compose -f /opt/nostream/docker-compose.yml logs nostream | grep -oE 'client [0-9a-f.:]+ connected:' | sort | uniq -c | sort -rn | head +``` + +Use two clients on **different public IP addresses** for this check: clients on the same home network or VPN legitimately share one address, so two identical values prove nothing there. Compare each logged address with that client's expected public IP. If both match their own address, proxy trust is working; if both show the same address, trust is not working — that is the collision this section prevents. Turn the log level back to `info` afterwards; `debug` logs request headers for every connection. + +> `remoteIpHeader` set without a matching `trustedProxies` entry is silently ignored. The next client connection logs `WARNING: network.remoteIpHeader is set but network.trustedProxies is empty` at `warn`, which is visible at the default level — if that line appears, clients are not being identified individually. It is written per connection, not once at startup, so look for it with `sudo docker compose logs nostream | grep remoteIpHeader` while a client is connected. + +### Relay metadata (optional) + +To set your relay name, description, contact email or pubkey, **edit the `info` block that is already in the file** — do not add a second one. YAML rejects a duplicated key, and as above, a file that fails to parse is discarded entirely: + +```yaml +info: + relay_url: wss://relay.example.com + name: "My Nostr Relay" + description: "A community Nostr relay powered by nostream." + pubkey: "" + contact: "mailto:operator@example.com" +``` + +Anything you omit falls back to `resources/default-settings.yaml` in the image. + +**A malformed `settings.yaml` fails silently.** If the file does not parse, the relay logs the error and starts with the image defaults — so your relay name, proxy trust and every other override quietly disappear while the process still comes up healthy. + +Do not rely on a log grep to catch this: `docker compose logs` shows only the current container's output, so after any restart the line is gone and silence proves nothing. Verify the *effective* settings instead, which is what the relay actually loaded: + +```bash +curl -s -H 'Accept: application/nostr+json' http://127.0.0.1:8008/ | jq '{name, description}' +``` + +If `name` is `null` or shows the image default rather than the value you set, the file did not load. Note that the relay rewrites `settings.yaml` from the admin console, which drops comments but keeps values. + +--- + +## 7. Start the Stack + +Navigate to `/opt/nostream` and start the containers in the background: + +```bash +cd /opt/nostream +sudo docker compose up -d +``` + +Verify that all services started successfully: + +```bash +sudo docker compose ps +``` + +You should see: +- `nostream-cache`: Up and healthy (Redis) +- `nostream-db`: Up and healthy (PostgreSQL) +- `nostream-migrate`: Exited with status `0` (migrations completed) +- `nostream`: Up and running on `127.0.0.1:8008` + +Verify readiness from the host loopback: + +```bash +curl -s http://127.0.0.1:8008/healthz +curl -s http://127.0.0.1:8008/readyz +``` + +Expected output for `/readyz` (HTTP 200; the relay returns 503 when either dependency is down): +```json +{"status":"ok","database":{"ok":true},"redis":{"ok":true}} +``` +The key is `redis`, not `cache`, and a healthy status is `ok`. When relay broadcast fanout is enabled an extra `relayBroadcast` key appears. + +--- + +## 8. Put the Relay Behind TLS + +The relay listens on `127.0.0.1:8008` and requires a reverse proxy to terminate TLS on port 443 and handle WebSocket upgrades. + +Both options below depend on the proxy-trust settings from step 6. Without them the relay sees every client as the proxy — or, for a published port, the Docker gateway — and one busy client can rate-limit everyone else. + +### Option A: Caddy (Recommended) + +Caddy provides automatic HTTPS with Let's Encrypt certificates and native WebSocket proxying out of the box with zero boilerplate. + +1. **Install Caddy:** + ```bash + sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list + sudo apt update + sudo apt install -y caddy + ``` + +2. **Configure `/etc/caddy/Caddyfile`:** + ```text + relay.example.com { + reverse_proxy 127.0.0.1:8008 + } + ``` + +3. **Reload Caddy:** + ```bash + sudo systemctl reload caddy + ``` + +Caddy automatically provisions your TLS certificate and proxies WebSocket traffic to the relay. + +--- + +### Option B: Nginx + Certbot (Alternative) + +If you already run Nginx: + +1. **Install Nginx and Certbot:** + ```bash + sudo apt update + sudo apt install -y nginx certbot python3-certbot-nginx + ``` + +2. **Obtain certificate:** + ```bash + sudo certbot certonly --nginx -d relay.example.com + ``` + +3. **Configure Nginx virtual host (`/etc/nginx/sites-available/nostream`):** + ```nginx + server { + server_name relay.example.com; + listen 80; + listen [::]:80; + return 301 https://$host$request_uri; + } + + server { + server_name relay.example.com; + listen 443 ssl http2; + listen [::]:443 ssl http2; + + ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://127.0.0.1:8008; + proxy_http_version 1.1; + + # WebSocket upgrade headers + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + + # Forwarded client headers + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + + # Prevent timeout on idle WebSocket connections + proxy_read_timeout 1h; + proxy_send_timeout 1h; + } + } + ``` + +4. **Enable and reload:** + ```bash + sudo ln -s /etc/nginx/sites-available/nostream /etc/nginx/sites-enabled/ + sudo nginx -t + sudo systemctl reload nginx + ``` + +--- + +## 9. Verify the Public Relay + +Once your DNS and reverse proxy are active, verify your relay from the outside world: + +### 1. NIP-11 Relay Information Document +Fetch the NIP-11 JSON description over HTTPS: + +```bash +curl -s -H 'Accept: application/nostr+json' https://relay.example.com/ | jq . +``` + +You should receive a JSON response describing your relay capabilities and supported NIPs. + +### 2. Live Health Endpoints +```bash +curl -sf https://relay.example.com/healthz && echo "OK" +curl -s https://relay.example.com/readyz | jq . +``` + +### 3. Connect a Nostr Client +Open your favorite Nostr client (Coracle, Primal, Amethyst, Snort, or Damus) and add: +```text +wss://relay.example.com +``` + +You can also test connecting directly using command-line utilities such as [`nak`](https://github.com/fiatjaf/nak): + +```bash +# Query the relay for latest metadata events +npx nak req -l 1 wss://relay.example.com +``` + +--- + +## 10. Operations and Maintenance + +### Viewing Logs +View live logs from all services or specifically the relay container: + +```bash +cd /opt/nostream +sudo docker compose logs -f --tail=100 nostream +``` + +### Updating the Relay +When a new nostream version is released: + +1. Update your repository clone: + ```bash + cd ~/nostream + git pull + ``` +2. Refresh the release-managed files. `git pull` updates your clone, but the running stack reads the copies under `/opt/nostream` — `docker-compose.yml` and `postgresql.conf` — and they do not follow the repository on their own. When a release changes either file, re-run bootstrap, which refreshes both and keeps your existing `.env` and `.nostr/settings.yaml`; running it on every update is harmless and saves tracking which files changed: + ```bash + cd ~/nostream + sudo ./deploy/bootstrap.sh /opt/nostream + ``` + Diff the copies first if you edited either by hand; bootstrap overwrites both. +3. Pull the updated Docker image: + ```bash + sudo docker pull ghcr.io/cameri/nostream:latest + ``` +4. Run the automated recreate script: + ```bash + sudo ./deploy/recreate-relay.sh /opt/nostream + ``` +5. If the release changed `postgresql.conf` — or you re-applied your own sizing after step 2 overwrote it — restart the database. Step 4 recreates `nostream` and `nostream-migrate` only, and none of the settings in that file (`shared_buffers`, `maintenance_work_mem`, `max_connections`) apply to a running server: + ```bash + envval() { sudo sed -n "s/^$1=//p" /opt/nostream/.env | tr -d "\"'"; } + compose() { sudo docker compose -f /opt/nostream/docker-compose.yml "$@"; } + compose restart nostream-db + # `restart` returns before PostgreSQL accepts connections, and the healthcheck's + # 360 s start period means a slow start is normal, not a failure. + for _ in $(seq 1 60); do + compose exec -T nostream-db pg_isready -q -U "$(envval DB_USER)" 2>/dev/null && break + sleep 2 + done + compose exec -T nostream-db \ + psql -U "$(envval DB_USER)" -d "$(envval DB_NAME)" -c 'SHOW shared_buffers;' + ``` + The loop gives up after two minutes rather than hanging forever. If `pg_isready` never succeeds, read `compose logs nostream-db` before anything else — a rejected `postgresql.conf` stops PostgreSQL from starting at all. If the value does not match the file once it does answer, the container is still reading a stale copy: check that `docker-compose.yml` mounts `/opt/nostream/postgresql.conf` and not a path inside the image. + +This runs database migrations, recreates the container, and verifies readiness. +For zero-downtime rolling deploys with blue/green backends, consult the [`Deploy Runbook & HAProxy Guide`](../docs/DEPLOY-RUNBOOK.md). + +### Backups + +Take two kinds, because they fail differently: + +**A logical database dump** is the one that restores reliably. Copying `/opt/nostream/.nostr/data/` while PostgreSQL is running captures a torn, mid-write state that may not start again; use `pg_dump` instead: + +```bash +set -o pipefail # otherwise the pipeline exits with gzip's status and a failed pg_dump looks fine +# .env is mode 600 and root-owned, so read just the two values we need, stripping any quotes. +envval() { sudo sed -n "s/^$1=//p" /opt/nostream/.env | tr -d "\"'"; } +DB_USER="$(envval DB_USER)" +DB_NAME="$(envval DB_NAME)" +dump="$HOME/nostream-$(date +%F).sql.gz" +# Write beside the final name and rename only once the whole pipeline succeeds: a failed +# pg_dump must not leave a fresh .sql.gz that looks like tonight's backup. +sudo docker compose -f /opt/nostream/docker-compose.yml exec -T nostream-db \ + pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$dump.part" \ + && mv "$dump.part" "$dump" || { rm -f "$dump.part"; false; } +``` + +The rename only happens on success, so a failure leaves no `$dump` at all. Check the dump opens and is not empty before trusting it — `pg_dump` writing into a pipe can still produce a valid gzip of nothing: + +```bash +gzip -t ~/nostream-$(date +%F).sql.gz && [ "$(gunzip -c ~/nostream-$(date +%F).sql.gz | wc -c)" -gt 1000 ] && echo "archive OK" +``` + +Restore into an empty database: + +```bash +# .env is mode 600 and root-owned, so read just the two values we need, stripping any quotes. +envval() { sudo sed -n "s/^$1=//p" /opt/nostream/.env | tr -d "\"'"; } +DB_USER="$(envval DB_USER)" +DB_NAME="$(envval DB_NAME)" +gunzip -c ~/nostream-2026-10-29.sql.gz | sudo docker compose -f /opt/nostream/docker-compose.yml exec -T nostream-db \ + psql -U "$DB_USER" -d "$DB_NAME" +``` + +**Configuration** can be copied directly — these are plain files: +- `/opt/nostream/.env` (secrets and credentials, mode 600) +- `/opt/nostream/.nostr/settings.yaml` (if you created one) +- `/opt/nostream/docker-compose.yml` (your copy may carry local edits) + +A filesystem snapshot of the whole host taken with the database stopped is the only safe way to back up the data directory itself. + +Test a restore before you need one. + +--- + +## Next Steps + +- **Monetize with Admission Fees:** See [`Accepting Payments`](../README.md#accepting-payments) to enable Lightning / LNURL / Zebedee pay-to-relay features. +- **Onion & I2P Hidden Services:** See [`CLI Reference & Common Workflows`](../CLI.md#common-workflows) to expose your relay anonymously. +- **Admin Console & Operations:** See [`Configuration`](../CONFIGURATION.md) to enable the web-based Operator Console at `/admin`. diff --git a/docs/rewrite/nip-parity.md b/docs/rewrite/nip-parity.md new file mode 100644 index 000000000..4ca4d95f4 --- /dev/null +++ b/docs/rewrite/nip-parity.md @@ -0,0 +1,867 @@ +# NIP parity audit: the registry at 2026-10-15 against nostream 3.5.1 + +## 1. What was audited, and how + +**Registry.** [`nostr-protocol/nips`](https://github.com/nostr-protocol/nips) at +`a79e21d90fce5465b50ef385dd368bba99ec4a0b` (2026-10-05, *"NIP-92: imeta tags for kind 0 +picture and banner (#2494)"*), resolved as the last commit before +`2026-10-15T00:00:00Z` (`git log -1 --before='2026-10-15T00:00:00Z'`). **The audit date +is 2026-10-15.** The registry tip at the time of writing is `ba59df89` (2026-10-16, +*"Fix typos (#2503)"*): it is a day after the audit date and is **not** the audited +revision. 99 NIP documents exist at the cutoff, and this document has a row for every +one of them. + +Registry paths are cited as they exist at the cutoff: at this revision the documents +sit at the repository root, so they are cited as `11.md`, `67.md` and so on. The +registry's own link markup inside a quoted sentence is rendered as plain `text (file)`, +so a quotation here is verbatim except for that one substitution. + +**This audit and the rewrite.** It is Phase 0 of the Rust rewrite tracked in +[nostream#860](https://github.com/cameri/nostream/issues/860), and it is +[nostream#861](https://github.com/cameri/nostream/issues/861)'s deliverable. Every +`src/…` citation below is to **nostream 3.5.1** (`041fe34e`) — the implementation that +was measured — not to the planned integration branch `rewrite/main`, where those files +will not exist. The porting oracle is the TypeScript suite **`test/` at `041fe34e`, +where a test exists**: #860 requires a Rust module to reproduce the TypeScript module's +observable behaviour, so `test/` is the evidence that survives the rewrite and a +`src/…` reference is provenance rather than a description of the target. `rewrite/main` +is planned to be cut from the capture commit `242a3001`, and that tree is **not** the +oracle: 14 test +files changed in the window, including the NIP-77 wire tests +(`test/unit/utils/nip77.spec.ts` and `test/unit/utils/negentropy-sessions.spec.ts`, both +new), the `isReconciliationAuthorized` cases added to `test/unit/utils/nip42.spec.ts`, +and the NIP-11 `supported_nips` expectation in +`test/integration/features/nip-11/nip-11.feature.ts`. Five behaviours this document +reports have no test at all — `min_prefix`, `supported_nip_extensions`, `supported_mips`, +`max_event_tags`, and NIP-78's kinds 78 and 30078 (`grep -rn` over `test/` returns 0 for +each of the first four, for `30078`, and for the word-bounded `\b78\b`; a bare `78` +matches 98 lines across 21 files, all inside hex ids) — so a parity test for each is +Phase 2 work, not something the suite already pins. + +**nostream.** The capture commit is `242a3001…` (`chore: release nostream@3.4.0`, +2026-10-13, version 3.4.0). This audit absorbs the **24 commits** that landed between +it and the grading tip `041fe34e` (*"docs: link the project website from the README +intro (#855)"*, version 3.5.1); §6 sorts them. The worktree tip is `811fbb5`, one +commit past the grading tip, and it touches no `src/` file. + +**Baseline agreement.** `package.json:7` lists 25 advertised ids, and the tip and the +pin carry the byte-identical file: `git rev-parse 811fbb5:package.json` and +`git rev-parse 041fe34e:package.json` both answer +`3cce1f408438a4ef038ccb193d5182ac5f709668`. The `Advertised` column below and +`package.json` therefore agree at both commits; a later push that changed +`supportedNips` would +break that agreement. `supportedNipExtensions` is `[]` (`package.json:34`) and +`supportedMips` is `[0,1,2,3]` (`package.json:35`). At serve time `supported_nips` is +filtered, so 43 appears only while NIP-43 is enabled and 77 only while NIP-77 is +active (`src/handlers/request-handlers/root-request-handler.ts:94-96`). + +**The delta window: `2024-01-01`.** "Changed since nostream implemented it" needs a +boundary, and nostream carries no per-NIP implementation date — its changelog is +release-scoped, and a `git log -S` walk per NIP would guess at a commit. `2024-01-01` +is a **stated, reproducible boundary**, not a measured implementation date: every +registry file whose last commit falls on or after that date, and that nostream either +advertises or implements, is examined in §4 or §5. Those are the **candidates**: 31 +NIPs, each named outside the table. Changing the boundary would be changing a rule, not +correcting an observation. + +## 2. The disposition table + +One row per registry NIP, in the order the registry lists them — its own order is +lexicographic, so the hex ids (`5A`, `7D`, `A0`…) sit among the decimal ones. + +- **Status** is the registry's own tag read from the NIP body at the cutoff — the + first backticked status word — with `unrecommended` appended where the registry + marks it. Three bodies carry no status tag (46, 92, A0) and the cell says so. +- **Summary** is written from the NIP body at the cutoff. +- **Advertised** is membership in `supportedNips` at `041fe34e` (`package.json:7`), + never the README's list — the two disagree, and §7 records how. +- **In nostream** is judged against the code at the worktree tip: `implemented` means + NIP-specific code carries the NIP's relay-side work, `partial` means an artifact — + a kind or tag constant, a codec, a comment, or code that implements an earlier text + of the number — exists without the work being done, `absent` means no NIP-specific + artifact at all. +- **Delta** is the relay-visible difference between the registry text at the cutoff + and that code, or `none`. Every row that does not say `none` is argued in §4. +- **Disposition** uses §3's three words. + +| NIP | Status | Summary | Advertised | In nostream | Delta | Disposition | +| --- | --- | --- | --- | --- | --- | --- | +| 01 | draft | Base protocol: event ids and signatures, filters, REQ/EVENT/CLOSE, EOSE/OK/CLOSED/NOTICE, replaceable and ephemeral kinds, deletion. | yes | implemented | prefix matching on ids and authors where 01.md requires exact 64-character values | must-have | +| 02 | final | Kind 3 follow list: a p tag per followed profile, replaceable so only the latest is kept. | yes | implemented | none | must-have | +| 03 | draft, unrecommended | Kind 1040 OpenTimestamps attestation carrying an OTS proof for another event. | yes | implemented | none | must-have | +| 04 | final, unrecommended | Kind 4 encrypted direct message: NIP-04 ciphertext in content with a p tag per recipient. | yes | partial | none | must-have | +| 05 | final | Maps a pubkey to a DNS internet identifier through the nip05 field of kind 0 metadata. | no | implemented | none | must-have | +| 06 | draft, unrecommended | Derives a Nostr keypair from a BIP39 mnemonic seed phrase. | no | absent | none | out-of-scope | +| 07 | draft | Defines the window.nostr object browsers and extensions expose for signing. | no | absent | none | out-of-scope | +| 08 | final, unrecommended | Standardizes how clients render inline mentions inside text note content. | no | absent | none | out-of-scope | +| 09 | draft | Kind 5 deletion request: e and a tags naming events the author asks relays to delete. | yes | partial | `DeleteEventStrategy` collects valid `e` tags only and calls `deleteByPubkeyAndIds`; an `a`-only request leaves its targets untouched (`src/handlers/event-strategies/delete-event-strategy.ts:22-31`) | must-have | +| 10 | draft | Kind 1 text notes, with e and p tag conventions for replies and threads. | no | absent | none | out-of-scope | +| 11 | draft | Relay information document: a JSON capabilities document served over HTTP at the relay URI. | yes | implemented | four non-spec limitation keys (two removed from 11.md) plus two non-standard top-level keys | must-have | +| 12 | final | Tombstone: generic tag queries moved to NIP-01. | yes | implemented | none | must-have | +| 13 | draft | Proof of work: leading zero bits on the event id, used to deter spam and set admission policy. | yes | implemented | none | must-have | +| 14 | draft | Defines the subject tag for kind 1 text events. | yes | partial | none | must-have | +| 15 | draft, unrecommended | Nostr Marketplace: a client protocol for stalls and products built on addressable events. | yes | partial | the advertised number now means Marketplace, while nostream's NIP-15 is the obsolete EOSE NIP | out-of-scope | +| 16 | final | Tombstone: event treatment for replaceable and ephemeral kinds moved to NIP-01. | yes | implemented | none | must-have | +| 17 | draft | Private direct messages built on NIP-44 encryption wrapped in NIP-59 gift wraps; relays store kind 1059. | yes | implemented | none | must-have | +| 18 | draft | Kind 6 reposts of kind 1 notes and kind 16 generic reposts of any event. | no | partial | none | out-of-scope | +| 19 | draft | bech32 encodings such as npub, note, nevent and naddr for displaying and sharing entities. | no | partial | none | out-of-scope | +| 20 | final | Tombstone: command results, the OK and CLOSED messages, moved to NIP-01. | yes | implemented | none | must-have | +| 21 | draft | The nostr: URI scheme for referencing Nostr entities inside text. | no | absent | none | out-of-scope | +| 22 | draft | Kind 1111 comment: a threading note scoped to a root event or I-tag, with uppercase root and lowercase parent tags. | yes | partial | the advertised number now means Comment, while nostream's NIP-22 behaviour is the old created_at limits | must-have | +| 23 | draft | Kind 30023 addressable long-form articles. | no | absent | none | out-of-scope | +| 24 | draft | De-facto extra metadata fields and tags, such as alt and content-warning. | no | absent | none | out-of-scope | +| 25 | draft | Kind 7 reactions whose content is +, - or an emoji. | yes | implemented | none | must-have | +| 26 | draft, unrecommended | Delegated signing: a delegation tag lets one keypair sign events attributed to another pubkey. | no | absent | none | out-of-scope | +| 27 | draft | Standardizes inline references to other events and profiles inside event content. | no | absent | none | out-of-scope | +| 28 | draft, unrecommended | Public chat: kinds 40 to 49 for channels, channel metadata and channel messages. | yes | partial | none | must-have | +| 29 | draft | Relay-based groups: relay-generated group metadata and closed-membership message rules. | no | absent | none | out-of-scope | +| 30 | draft | Custom emoji referenced by shortcode with emoji tags pointing at images. | no | absent | none | out-of-scope | +| 31 | draft, unrecommended | Uses the alt tag so clients can render an unknown event kind as readable text. | no | absent | none | out-of-scope | +| 32 | draft | Labeling: kind 1985 label events and l/L tags that classify other events. | no | absent | none | out-of-scope | +| 33 | final | Tombstone: parameterized replaceable events renamed to addressable events and moved to NIP-01. | yes | implemented | none | must-have | +| 34 | draft | Git stuff: repository, patch, issue and status events for code collaboration over Nostr. | no | absent | none | out-of-scope | +| 35 | draft | Kind 2003 torrent index events carrying enough metadata to build a magnet link. | no | absent | none | out-of-scope | +| 36 | draft | The content-warning tag, letting clients gate display of sensitive content. | no | absent | none | out-of-scope | +| 37 | draft | Kind 31234 encrypted storage for unsigned draft events of any kind. | no | absent | none | out-of-scope | +| 38 | draft | Kind 30315 user status events such as what someone is listening to or doing. | no | absent | none | out-of-scope | +| 39 | draft | Kind 10011 lists linking a profile to identities on other platforms with i tags. | no | absent | none | out-of-scope | +| 40 | draft | The expiration tag: a unix timestamp after which relays and clients should drop the event. | yes | implemented | none | must-have | +| 42 | draft | Client authentication to relays by signing an ephemeral kind 22242 event carrying a challenge. | yes | implemented | EOSE never carries the NIP-67 auth hint; the mixed-filter trim needs nip42.restrictedReads.enabled, off by default | must-have | +| 43 | draft | Relay access metadata and join and leave requests, with membership lists and relay-signed events. | yes | implemented | kind 28935 invite-request minting was removed from 43.md, which now points claims at NIP-86 | must-have | +| 44 | optional | Versioned encrypted payload format; version 2 uses ChaCha20, HMAC-SHA256 and base64. | yes | implemented | 65535-byte plaintext cap and 2-byte length prefix against 44.md's extended 6-byte format | must-have | +| 45 | draft | The COUNT verb, returning how many events match a set of filters. | yes | implemented | none | must-have | +| 46 | no status tag | Remote signing: a client and a bunker exchange requests over kind 24133 events. | no | absent | none | out-of-scope | +| 47 | draft | Nostr Wallet Connect: clients drive a remote Lightning wallet over Nostr events. | no | partial | none | out-of-scope | +| 48 | draft | The proxy tag linking an event bridged from another protocol back to its source object. | no | absent | none | out-of-scope | +| 49 | draft | ncryptsec: password-based encryption of a private key. | no | absent | none | out-of-scope | +| 50 | draft | The search filter, a full-text search extension to structured REQ filters. | yes | implemented | none | must-have | +| 51 | draft | Lists: replaceable events holding public and private references to anything. | no | absent | none | out-of-scope | +| 52 | draft | Calendar events: date-based and time-based addressable event kinds. | no | absent | none | out-of-scope | +| 53 | draft | Live streaming and spaces: kinds advertising streams and who takes part. | no | absent | none | out-of-scope | +| 54 | draft | Kind 30818 addressable wiki articles, with many articles per subject. | no | absent | none | out-of-scope | +| 55 | draft | Android signer application protocol, signing without exposing the private key. | no | absent | none | out-of-scope | +| 56 | optional | Kind 1984 report events signalling objectionable content to clients and relays. | no | implemented | none | must-have | +| 57 | draft | Lightning zaps: kind 9734 zap requests and kind 9735 zap receipts. | no | partial | none | out-of-scope | +| 58 | draft | Badge definition, award and profile badge events. | no | absent | none | out-of-scope | +| 59 | optional | Gift wrap: encapsulates any event in a kind 1059 envelope to hide its metadata. | no | implemented | none | must-have | +| 5A | draft | Static websites hosted from Blossom assets, with manifest and snapshot events. | no | absent | none | out-of-scope | +| 60 | draft | Cashu wallets whose state is stored in relay events. | no | absent | none | out-of-scope | +| 61 | draft | Nutzaps: P2PK Cashu tokens where the payment is the receipt. | no | absent | none | out-of-scope | +| 62 | draft | Request to Vanish: asks a relay to delete every event from a pubkey. | no | implemented | vanish soft-deletes rows instead of deleting them, and keeps gift wraps that p-tag the pubkey | must-have | +| 64 | draft | Kind 64 chess games in PGN format. | no | absent | none | out-of-scope | +| 65 | draft | Kind 10002 relay list metadata advertising where a user reads and writes. | yes | implemented | none | must-have | +| 66 | draft | Relay liveness monitoring: monitor announcements and relay discovery events. | no | implemented | none | must-have | +| 67 | draft | Extends EOSE with an optional third element carrying finish, more or auth hints. | no | absent | EOSE is two elements only; no finish, more or auth hints, and 67 is not advertised | opportunistic | +| 68 | draft | Kind 20 picture-first feeds whose images are referenced with imeta tags. | no | absent | none | out-of-scope | +| 69 | draft | Peer-to-peer order events for trading platforms. | no | absent | none | out-of-scope | +| 70 | draft | Protected events: a - tag means only the author may publish the event to a relay. | no | implemented | none | must-have | +| 71 | draft | Video events for externally hosted video content. | no | absent | none | out-of-scope | +| 72 | draft, unrecommended | Moderated communities: kind 34550 community definitions plus approval events. | no | absent | none | out-of-scope | +| 73 | draft | External content ids such as ISBN, podcast GUID and ISAN, referenced with i tags. | no | absent | none | out-of-scope | +| 75 | draft | Zap goals: fundraising goal events funded by zaps. | no | absent | none | out-of-scope | +| 77 | draft | Negentropy syncing: NEG-OPEN, NEG-MSG, NEG-CLOSE and NEG-ERR for set reconciliation. | yes | implemented | none in the registry text; the advertisement landed after the capture commit | must-have | +| 78 | draft | Arbitrary custom application data in kind 78 and addressable kind 30078 events, behind AUTH. | no | absent | on a default deployment, kind 78 and 30078 are accepted and served without the AUTH guard 78.md asks for; item 7 closes unauthenticated serving for reads that name the kinds, not the owner-only rule or a kindless COUNT | opportunistic | +| 7D | draft | Forum threads: kind 11 thread events with titles and replies. | no | absent | none | out-of-scope | +| 84 | draft | Kind 9802 highlight events pointing at content a user found valuable. | no | absent | none | out-of-scope | +| 85 | draft | Trusted assertions: providers publish signed computed claims about users. | no | absent | none | out-of-scope | +| 86 | draft | Relay management API: a JSON-RPC-like HTTP API for relay administration. | no | absent | none | opportunistic | +| 87 | draft | Cashu mint and Fedimint discovery events. | no | absent | none | out-of-scope | +| 88 | draft | Poll events and their response events. | no | absent | none | out-of-scope | +| 89 | draft | Kind 31989 and 31990 recommended application handlers for unknown event kinds. | no | implemented | none | must-have | +| 90 | draft, unrecommended | Data vending machines: job request and result events for paid computation. | no | implemented | none | must-have | +| 92 | no status tag | imeta tags adding media metadata such as url, mime type, blurhash and dimensions. | no | absent | none | out-of-scope | +| 94 | draft | Kind 1063 file metadata events describing shared files. | no | absent | none | out-of-scope | +| 96 | draft, unrecommended | An HTTP file storage API for files referenced from Nostr events. | no | absent | none | out-of-scope | +| 98 | draft | Ephemeral kind 27235 event authorizing an HTTP request. | no | implemented | none | must-have | +| 99 | draft | Kind 30402 classified listings for products and services. | no | absent | none | out-of-scope | +| A0 | no status tag (body says Draft) | Kinds 1222 and 1244 voice messages carrying short audio URLs. | no | absent | none | out-of-scope | +| A3 | draft | payto tags declaring payment targets such as bitcoin addresses or Lightning addresses. | no | absent | none | out-of-scope | +| A4 | draft | Kind 24 public plaintext messages to one or more users. | no | absent | none | out-of-scope | +| B0 | draft | Kind 39701 web bookmarks keyed by URI. | no | absent | none | out-of-scope | +| B7 | draft | How clients use Blossom servers for media, including kind 10063 server lists. | no | absent | none | out-of-scope | +| BE | draft, unrecommended | Nostr over BLE: device advertisement and GATT characteristics for syncing. | no | absent | none | out-of-scope | +| C0 | draft | Kind 1337 code snippet events with language, extension and dependency tags. | no | absent | none | out-of-scope | +| C7 | draft | Kind 9 chat messages and q-tag replies. | no | absent | none | out-of-scope | +| CC | draft | Geocaching events: kind 37516 cache listings and log events. | no | absent | none | out-of-scope | +| EE | final, unrecommended | End-to-end encrypted group messaging using the MLS protocol. | no | absent | none | out-of-scope | +| F4 | draft | Podcast episodes as Nostr events, for podcast players. | no | absent | none | out-of-scope | + +## 3. What the three dispositions mean + +- **`must-have`** — the rewrite cannot ship without this NIP's relay-side behaviour. + That covers two cases: the NIP is advertised today (`package.json:7`), so dropping + it would change what the relay claims and does; or it is implemented today without + being advertised (NIP-05, 56, 59, 62, 66, 70, 89, 90, 98), so dropping it would + silently delete a feature from the rewrite's parity target. +- **`opportunistic`** — the NIP asks something of a relay that nostream does not do + today, and the work is small and local enough to take while the surrounding area is + being rewritten. It is not required for parity. +- **`out-of-scope`** — no NIP-specific relay work is owed. This is a statement about + what the NIP asks of a relay, never about who its audience is: a NIP that defines + event kinds a relay stores and serves under the generic rules asks for nothing + NIP-specific, and that is the reason given for every such row. Where a NIP does ask + a relay for real work that the rewrite will not carry (NIP-29 groups, NIP-96 file + storage), the reason names that work. + +`must-have` holds 33 rows: 01, 02, 03, 04, 05, 09, 11, 12, 13, 14, 16, 17, 20, 22, 25, +28, 33, 40, 42, 43, 44, 45, 50, 56, 59, 62, 65, 66, 70, 77, 89, 90, 98. Four of them +(12, 16, 20, 33) are tombstones in the registry — their text moved into NIP-01 — but +nostream advertises them and implements the behaviour they name, so the rewrite keeps +the behaviour; whether it should keep advertising a tombstone is a §9 question. Four +more (04, 14, 22, 28) carry no code for the NIP the number now means; for those +`must-have` means "preserve the generic behaviour that satisfies them", which costs +nothing and avoids a silent parity change. For 22 the code that does exist — the +`created_at` bounds — is NIP-11's behaviour (§4). Against the advertised set at the +grading tip `041fe34e` — 25 ids — `must-have` is **24 of the 25** plus the nine live +unadvertised subsystems. NIP-15 is the one advertised number that is not `must-have` +(`out-of-scope`): the number now means Marketplace and the relay does no NIP-15 work +(§4). NIP-77 is the id added after capture, so against `242a3001`'s 24-id list the same +set is 23 of 24. The 25 advertised ids all still carry a disposition, which is what +#861 asks. + +`opportunistic` holds 3 rows: + +- **NIP-67** — the third `EOSE` element is a small addition to the message nostream + already builds, and NIP-42 now refers to it (§4). +- **NIP-78** — the **serving** half is a configuration of the read guard that already + exists, though closing unauthenticated reads leaves the owner rule and a kindless + COUNT open; the accept half needs a write gate the relay has only globally and off + (§4). +- **NIP-86** — a relay management API is a real surface, but nostream already has an + admin HTTP API, so aligning it with NIP-86 is coherent rather than new; `createclaim` + is also where NIP-43 now points the invite flow (§4). + +`out-of-scope` holds the other 63 rows, and the reasons are five: + +1. **Client-side event conventions** — the NIP defines kinds, tags or client-side + encodings that a relay stores and serves unchanged, or never parses from protocol + traffic (08, 10, 18, 19, 21, 23, 24, 27, 30, 31, 32, 34, 35, 36, 37, 38, 39, 48, 51, + 52, 53, 54, 57, 58, 5A, 60, 61, 64, 68, 69, 71, 72, 73, 75, 7D, 84, 85, 87, 88, 92, + 94, 99, A0, A3, A4, B0, B7, BE, C0, C7, CC, EE, F4). The relay's duty is generic + storage, already carried by the event pipeline; nothing in the NIP asks a relay to + validate, index or transform the event. NIP-19 (bech32 encodings) and NIP-21 + (`nostr:` URIs) are here for the second half of that test: they define what clients + build and a relay never parses them from client protocol traffic. NIP-19's row is + `partial` for that reason: nostream has a bech32 codec + (`src/utils/transform.ts:146-165`) that decodes an `npub1…` from the relay's own + configuration — `info.pubkey` (`root-request-handler.ts:50,76`) and `info.self`, + through `parseRelayPubkey` (`nip43-invites.ts:29-47`, called from `nip43.ts:60,81` + and `cli/commands/invite.ts:158`) — and from an invoice request body + (`src/controllers/invoices/post-invoice-controller.ts:67-78`); it also encodes one + back into the invoice description (`:112`), and + `test/unit/handlers/request-handlers/root-request-handler.spec.ts:230-235` pins the + decode. What NIP-19 has that nostream does not is its TLV prefixes (`nprofile`, + `nevent`, `naddr`, deprecated `nrelay`) and any protocol use: no Nostr protocol + message carries a bech32 value (an event's `content` may, unparsed). +2. **Client-side signing and key protocols** — 06, 07, 46, 47, 49, 55. These describe + how a client holds or uses a key. A relay is at most a transport: 46 and 47 define + events a relay stores generically, and 06, 07, 49 and 55 never touch a relay. + (nostream does ship a NIP-47 *client* for operator payments in + `src/payments-processors/nwc-payments-processor.ts`, which is why the row says + `partial`; that is not a relay obligation.) +3. **Relay features outside the parity target** — 29 asks the relay to generate group + metadata and enforce per-group membership and moderation rules; 96 asks for a + separate HTTP file-storage service. Both are product surfaces nostream has never + had, and neither is needed to match today's relay or the advertised set. +4. **Relay work the registry has withdrawn** — 26 asks relays to answer + delegation-tagged queries and to let a delegator delete the delegatee's events; the + NIP is `unrecommended` upstream and nostream has no delegation path, so the rewrite + does not take it. +5. **NIP-15** alone — the registry number no longer means what nostream advertises + under it, for the reason §4 records. + +## 4. Behavioural deltas + +Each subsection names the registry file and the section the claim rests on, and the +nostream path affected. A delta is recorded only where the reading supports it. + +### NIP-01 + +`01.md` §"From client to relay: sending events and creating subscriptions" requires +exact filter values: *"The `ids`, `authors`, `#e` and `#p` filter lists MUST contain +exact 64-character lowercase hex values."* nostream accepts hex **prefixes** from four +characters up for the first two of those lists: `src/schemas/base-schema.ts:11` +(`prefixSchema = z.string().regex(lowerHexRegex).min(4).max(64)`) is applied to `ids` +and `authors` in `src/schemas/filter-schema.ts:10-11`, and both the database query +(`applyHexCriteria` over `event_pubkey`/`event_id`, +`src/repositories/event-repository.ts:261-285`) and the in-memory matcher +(`src/utils/event.ts:60,76-79`) compare by prefix. The relaxation is advertised as +`limitation.min_prefix` (`src/handlers/request-handlers/root-request-handler.ts:112`, +default 4 at `resources/default-settings.yaml:350`). A client sending +`{"ids": ["abcd"]}` therefore gets results where the registry text requires a +64-character value. (`#e` and `#p` are a smaller difference: they are matched exactly, +`src/utils/event.ts:55`, and their length is not validated — a short value simply +matches nothing.) This is a permissive extension, not a broken feature, but it is +relay-visible and it is advertised. + +It is also not free, which matters for the rewrite. A full 64-character value goes +through `orWhereIn` on `event_id`/`event_pubkey` (`event-repository.ts:274-276`), which +the btrees on those columns serve +(`migrations/20220524_153400_create_events_table.js:4-5`). Every shorter value goes +through an expression predicate instead — `substring("event_id" from 1 for ?) = ?` or +the odd-nibble `BETWEEN` (`event-repository.ts:278-288`) — and no index on the full +columns can serve that, so a **prefix-only** filter — one with no other selective +criterion — is a scan of `events` proportional to the table rather than to the result +set. Keeping the +extension in Rust is cheap only if a prefix becomes a range seek (or a functional index +does), which is why §9 item 8 says what "keep it" has to buy. + +The window's change to this section — the zero-`limit` clarification at `01.md:151` +("When `limit` is zero, the relay MUST NOT return stored events for that filter … MUST +keep the subscription active") — is **met**: the filter schema allows `0` +(`src/schemas/filter-schema.ts:15`), `findByFilters` passes it to the query +(`src/repositories/event-repository.ts:88`), knex emits `limit 0` rather than omitting +the clause, and `isEventMatchingFilter` never consults `limit` (`src/utils/event.ts:39-125`), +so the subscription still receives new matching events after the `EOSE`. + +### NIP-09 + +`09.md` requires a kind-5 deletion request to name its targets with `e` tags (events) +and `a` tags (addressable events), and says the relay should refuse or ignore requests +that do not match the author. nostream implements the `e` half only: +`DeleteEventStrategy` filters for a valid 64-hex `e` tag and then calls +`eventRepository.deleteByPubkeyAndIds`, and `deleteByPubkeyAndIds` takes event ids — +there is no addressable path +(`src/handlers/event-strategies/delete-event-strategy.ts:22-31`, +`src/@types/repositories.ts:45-46`). An `a`-only request therefore deletes nothing and +is acknowledged like any other kind-5 event, so a client that follows the spec's +addressable branch gets a success response and no deletion. The registry text did not +change (the June commit was a fence-language sweep); the gap is nostream's, and Phase 2 +carries it as item 12 in §9. + +### NIP-11 + +`11.md` §"Server Limitations" is the authoritative `limitation` block, and it no longer +contains two keys nostream emits. `max_filters` was removed by `611b635` (2025-04-13, +*"nip-11: remove max filters, add max/default query limit, update example. (#1821)"*) +and `min_prefix` by `89915e0` (2023-08-20, *"Remove min_prefix mentions from NIP-11 +after prefix search was removed from NIP-01"*). nostream still emits both — +`max_filters` at `src/handlers/request-handlers/root-request-handler.ts:105` and +`min_prefix` at `:112` — alongside `max_filter_values` (`:109`) and `search_supported` +(`:142`), which `11.md` has never defined, and the top-level +`supported_nip_extensions` (`:97`) and `supported_mips` (`:98`). + +This is not a conformance failure: `11.md` §"Field Descriptions" says *"Any field may +be omitted, and clients MUST ignore any additional fields they do not understand."* +The delta is that two of the six keys advertise limits the registry took away, so a +client reading the current spec learns nothing from them, while a client reading an +older copy is told a prefix floor the relay does enforce. §8 gives the full +field-by-field comparison. + +### NIP-15 + +`15.md` §"Nostr Marketplace" is now the marketplace protocol: stalls and products +built on addressable events, chosen by clients. nostream's only NIP-15 artifact is a +comment on the `EOSE` builder — `// NIP-15` above +`src/utils/messages.ts:27-30` — because NIP-15 used to be *End of Stored Events +Notice*, whose message is now defined in `01.md` §"From relay to client: sending +events and notices". `README.md:79` still spells the old meaning out: *"NIP-15: End of +Stored Events Notice"*. Since 15 is in `supportedNips` (`package.json:7`) and served +at `src/handlers/request-handlers/root-request-handler.ts:94-96`, a current client +reads the advertisement as marketplace support that nostream does not have, while the +behaviour it does have (EOSE) needs no advertisement at all. + +### NIP-22 + +`22.md` §"Comment" now defines kind `1111`: a threading note whose uppercase tags +point at a root (`K`, `E`, `A`, `I`) and whose lowercase tags point at the parent +(`k`, `e`, `a`, `i`). The number's previous content was *moved*, not deleted with the +file: `d784820` (2023-09-01, *"add NIP-22 created_at limits to Server-Limitations"*) +put the two `created_at` limits into `11.md` §"Server Limitations" as +`created_at_lower_limit`/`created_at_upper_limit`, and `d67988e` (2023-12-01, *"delete +NIP-22 (#897)"*) deleted `22.md` and dropped only the `[NIP-22](22.md)` links from +those two fields. They are current NIP-11 keys at the cutoff (`11.md:157-159`), and +they are exactly what nostream advertises (`src/handlers/request-handlers/root-request-handler.ts:138-139`), +with the bounds enforced at `src/handlers/event-message-handler.ts:204-216`. So the +limits are NIP-11 parity today, not an orphaned NIP-22 artifact — which is worth +saying, because a reader who takes `supported_nips`' 22 at face value would look for +Comment support in the wrong place. nostream has none of it: there is no kind 1111 +constant in `src/constants/base.ts`, the string `NIP-22` appears nowhere in `src/`, and +`README.md:82` still says *"NIP-22: Event `created_at` Limits"*. The relay-visible +delta is the advertisement: `supported_nips` includes 22 (`package.json:7`), which now +promises Comment support; kind 1111 events are stored only because they fall through to +`DefaultEventStrategy` (`src/factories/event-strategy-factory.ts:108`), with none of +the NIP's tag rules checked. + +### NIP-42 + +`42.md` §"`auth` hint in `EOSE`" says: *"Relays MAY use the `"auth"` hint in an `EOSE` +message (as defined in NIP-67 (67.md)) to notify the client that more results may be +available if they authenticate. In this case, the relay MUST send the `AUTH` challenge +before the `EOSE` message."* nostream's `EOSE` is built as a two-element array +(`src/utils/messages.ts:28-30`) and emitted at +`src/handlers/subscribe-message-handler.ts:213`; no code path appends a third element. +The `AUTH` challenge is sent unconditionally at connect +(`src/adapters/web-socket-adapter.ts:102-104`), but it is never tied to an `EOSE`. + +The relay-visible consequence is in the mixed-filter case, and it only arises when the +read gate is **on**. `getRestrictedReadKinds` returns an empty list unless +`nip42.restrictedReads.enabled` is true (`src/utils/nip42.ts:17-23`), and the shipped +default is `false` (`resources/default-settings.yaml:82-86`; `CONFIGURATION.md:212` says +"Defaults to false", and `:213` gives `[4, 1059]` as the kinds). With an empty list, +`createReadAuthorizationGuard` returns `() => true` (`:48-50`) and +`isSubscriptionAuthRequired` returns `false` (`:77-79`) — the two gates on the REQ and +live paths (`src/handlers/subscribe-message-handler.ts:68,223`, +`src/adapters/web-socket-adapter.ts:137`). The setting has four consumers in all: the +other two are `isReconciliationAuthorized` (`src/utils/nip42.ts:95-116`, used at +`neg-open-message-handler.ts:145` and `neg-msg-message-handler.ts:37`) and +`isCountAuthorized` (`:120-144`, used at `count-message-handler.ts:75`), both permissive +on an empty list too (`:101-103`, `:125-127`); `CONFIGURATION.md:212` names REQ, live +broadcasts and COUNT, and the code also covers NEG. They do not filter per event: they +scope a whole reconciliation or COUNT to the client's own `authors`/`#p`, so a rewrite +that ports only the REQ and live gates leaves COUNT and reconciliation event ids +un-scoped once the list is non-empty. COUNT's reading of a filter that names no kinds is +a defect rather than a convention: `isCountAuthorized` authorizes `{"filters": [{}]}` +(`:130-135`) and `filterSchema` accepts it (`src/schemas/filter-schema.ts:9-19`), so an +unauthenticated client can count kind-4 and 1059 events that `REQ` withholds — while +`CONFIGURATION.md:212` says the setting applies to COUNT, and reconciliation refuses the +same filter (`:105-109`). The rewrite takes reconciliation's reading; item 3 owns the +fix. `nip42.authRequired` does not cover reads: it gates publishing +(`src/handlers/event-message-handler.ts:104`). So **on a default deployment no +gate withholds anything**, and kind 4 and kind 1059 events are served to unauthenticated +clients — their metadata, since the payload is encrypted to its recipient. + +With `restrictedReads.enabled: true`, the gap this section is about appears: +`isSubscriptionAuthRequired` (`src/utils/nip42.ts:71-90`) closes a subscription only +when *every* filter names restricted kinds; a filter such as `{"kinds": [1, 4]}` is +served, and `createReadAuthorizationGuard` (`src/utils/nip42.ts:44-61`) drops the kind-4 +events. The client receives the kind-1 events and an `EOSE` with no signal that more +exists behind authentication — exactly the ambiguity the `auth` hint was added to +remove. The narrow `CLOSED auth-required` path +(`src/handlers/subscribe-message-handler.ts:68-77`) covers only the fully-restricted +case. + +The four checks of `42.md` §"Signed Event Verification" are all implemented in +`src/handlers/auth-message-handler.ts:22-63`: kind `22242` (`:13,25-28`), `created_at` +within ±600 s (`:14,40-45`), the `challenge` tag (`:47-53`) and the `relay` tag by +hostname (`:55-61`, `isRelayUrlMatch` at `:80-85`). The replay set at +`src/utils/nip42-session.ts:44-52` and the per-pubkey map at `:22` also satisfy +`42.md`'s multi-pubkey AUTH relaxation (`4c5d5ff`, 2025-09-06). So the delta is the +hint, not the verification. + +### NIP-43 + +`43.md` §"Join Request" ends, at the cutoff: *"Users can request a `claim` using the +NIP 86 (86.md) `createclaim` method."* The `## Invite Request` section that defined +a client `REQ` for kind `28935` was deleted by `62d5fed` (2026-09-23, *"Add claim +management to nip 86 (#2408)"*), which moved the claim operation into `86.md`. +nostream still mints on that `REQ`: `maybeIssueInviteCode` runs before the stored-event +query (`src/handlers/subscribe-message-handler.ts:89-104`), the filter test is +`src/utils/nip43.ts:89-92`, and the relay-signed kind-28935 event is built by +`src/utils/nip43-invites.ts:95-121`. A client that follows the current text calls a +NIP-86 method nostream does not expose, while the removed `REQ` path still answers. +`43.md` §"Implementation" still tells clients to request kind 28935 from relays +advertising 43, a contradiction left in the registry itself. nostream advertises 43 +only while the feature is on (`src/handlers/request-handlers/root-request-handler.ts:94-96`), +which keeps the advertisement honest about the old flow but not about the new one. + +### NIP-44 + +`44.md` §"Version 2" → "### Encryption" step 4 and "### Decryption" step 7 were +rewritten by `733a047` (2026-06-28, *"allow NIP-44 to encrypt more than 65535 bytes +(#1907)"*): plaintext may now be up to 4,294,967,295 bytes, and a length of 65536 or +more uses a 6-byte prefix (`[0x00, 0x00][u32]`) while shorter lengths keep the 2-byte +`u16`. The decryption size floor and ceiling were correspondingly relaxed. nostream +still implements the pre-change format: `MAX_PLAINTEXT_SIZE = 65535` at +`src/utils/nip44.ts:5`, a 2-byte length prefix in `pad` at `:74-87`, and the old +payload bounds in `nip44Decrypt` (`:120-125`) and `validateNip44Payload` (`:162-172`). +The relay-visible path is gift-wrap admission: `validateNip44Payload` runs on every +kind-1059 event (`src/handlers/event-strategies/gift-wrap-event-strategy.ts:53-56`), +so a NIP-17 gift wrap whose NIP-44 payload exceeds the old cap is rejected with an +`invalid:` prefix even though it is valid under the cutoff text. + +### NIP-62 + +`62.md` §"Request to Vanish from Relay" requires: *"Relays MUST fully delete any +events from the `.pubkey` if their service URL is tagged in the event"*, and *"Relays +SHOULD delete all NIP-59 (59.md) Gift Wraps that p-tagged the `.pubkey`"*. nostream's +`VanishEventStrategy` calls `deleteByPubkeyExceptKinds` with kind 62 excluded +(`src/handlers/event-strategies/vanish-event-strategy.ts:22`) and +`src/repositories/event-repository.ts:498-508` implements that as an `UPDATE … SET +deleted_at = now()` — a soft delete that leaves every row in place — filtered to +`event_pubkey`, so gift wraps that merely p-tag the pubkey are untouched. The other +two requirements are met: re-publication is blocked with +`blocked: request to vanish active for pubkey` +(`src/handlers/event-message-handler.ts:360-375`), and the kind-62 request itself is +kept (`:22` excludes it). The delta matters because the NIP is explicitly about +deletion that is *"legally binding in some jurisdictions"*; a row that still exists is +not deleted, however invisible it is to clients. + +### NIP-67 + +`67.md` §"Specification": *"A relay that implements this NIP MAY append a third +element to the `EOSE` message: an array of hint strings"*, with `"finish"`, `"more"` +and `"auth"` defined, and §"Relay advertisement" says implementing relays *"SHOULD +include `67` in the `supported_nips` field"*. nostream sends the two-element form +(`src/utils/messages.ts:28-30`) from `src/handlers/subscribe-message-handler.ts:213`, +has no hint value anywhere (`grep -rn -iE '\bfinish\b|eoseHint' src/` finds only +unrelated `finish()` callbacks under `src/utils/relay-probe/`), and does not list 67 in +`package.json:7`. The delta is the one `67.md` §"Motivation" describes: a client cannot +tell "every stored event was sent" from "the relay's own cap cut the result", and a +client that cannot read restricted kinds gets no `"auth"` hint. This is the same gap +as `### NIP-42`, seen from the message's side; implementing it once closes both. + +### NIP-77 + +`77.md` §"Nostr Messages" defines four message shapes and their rules. Measured +against nostream: + +- **`NEG-OPEN`** (`77.md:31-44`) — the four-element shape is enforced by + `src/schemas/message-schema.ts:61-66` and handled by + `src/handlers/neg-open-message-handler.ts:30-130`. Met. +- **Separate subscription namespace; a re-open closes the old session** + (`77.md:42`) — `src/utils/negentropy-sessions.ts:12-20` keeps NIP-77 ids in their + own map, and `reserve` closes any session under the id first (`:21-24`). Met. +- **`NEG-ERR` with machine-readable prefixes** (`77.md:46-70`) — `blocked:` at + `src/handlers/neg-msg-message-handler.ts:26` and + `src/handlers/neg-open-message-handler.ts:56`, `closed:` at + `neg-msg-message-handler.ts:32`, `invalid:` at `:53` and + `neg-open-message-handler.ts:106`, and `error:` at `neg-open-message-handler.ts:70,115`. + Met. (`auth-required:` at `neg-msg-message-handler.ts:40` is a NIP-42 prefix the + document's "suggested" list does not enumerate; the format is the same.) +- **The optional 4th element carrying the record cap** (`77.md:64`) — emitted at + `src/handlers/neg-open-message-handler.ts:86` as + `createNegErrMessage(subscriptionId, 'blocked: this query is too big', maxEventsPerSession)`. + Met. +- **A `NEG-ERR` closes the subscription** (`77.md:70`) — `reject` closes before + replying (`neg-open-message-handler.ts:152-155`), and the malformed-message path + closes at `neg-msg-message-handler.ts:47-53`. Met. +- **`NEG-CLOSE` releases resources and draws no reply** (`77.md:86-97`) — + `src/handlers/neg-close-message-handler.ts:8-11`. Met. + +**Conclusion: no relay-visible divergence in the registry text.** The audit-window +fact is about the advertisement, not the protocol: the wire implementation landed at +`7409006` (*"feat(nip77): add NEG-OPEN/NEG-MSG/NEG-CLOSE wire protocol (#818)"*) and +the NIP-11 advertisement at `f0bcdcc` (*"feat(nip77): advertise NIP-77 in NIP-11 and +expose its settings in the admin console (#830)"*), both **after** the capture commit +`242a3001`. NIP-77 is therefore advertised at the grading tip and not at the capture +date, and `isNip77Active` (`src/utils/nip77.ts:14-15`) adds a second condition: the +Postgres backend. + +The bounds around all this are the relay's own, not the registry's: `77.md` sets no +frame or record size, while nostream defaults to 100000 events per session, three +sessions per connection and a 60000-byte frame (`src/utils/nip77.ts:5-11`), and +`NEG-OPEN` reads `maxEventsPerSession + 1` rows before deciding a set is too big +(`src/handlers/neg-open-message-handler.ts:64`). A rewrite sizing connection memory +should carry those numbers forward deliberately rather than rediscover them. + +### NIP-78 + +`78.md` §"AUTH" says: *"relays SHOULD require clients to perform the NIP-42 (42.md) `AUTH` +flow before accepting or serving `kind 78` and `kind 30078` events, and SHOULD only +serve these events to the authenticated owner, i.e. the client must authenticate with +the same pubkey as the event author"*. nostream has no such guard, in either of two +senses. The read gate itself is off by default — `getRestrictedReadKinds` +returns `[]` unless `nip42.restrictedReads.enabled` is true (`src/utils/nip42.ts:17-23`, +default `false` at `resources/default-settings.yaml:82-86`) — and even when it is on, +`DEFAULT_RESTRICTED_READ_KINDS` is `[4, 1059]` (`src/utils/nip42.ts:10-13`), so kinds 78 +and 30078 are not in the set the guard would drop. Admission has no NIP-78 branch +either: kind 78 reaches `DefaultEventStrategy` and kind 30078, being inside the +parameterized-replaceable range (`src/constants/base.ts:75,83`), reaches +`ParameterizedReplaceableEventStrategy` +(`src/factories/event-strategy-factory.ts:104-108`); neither strategy carries an AUTH +check. An unauthenticated client can therefore fetch another user's kind 78 or 30078 +events on a default deployment, and on an enabled-guard deployment too, where the NIP +asks the relay to refuse. The NIP does not define kind 30078 as a restricted kind +anywhere else, so the serving half of the fix is local to the read guard. `78.md:23` +asks for more on both sides: AUTH before **accepting** as well as serving, and serving +only to the author. The guard is owner-or-recipient instead — it admits the event's +author or any authenticated pubkey a `p` tag names (`src/utils/nip42.ts:29-41`, the `p` +branch at `:38-40`) — so that is a deviation from the NIP, not a reading of it, and it +has to be closed per kind: tightening the shared predicate would also deny kind-4 and +kind-1059 recipients, which is what recipient admission exists for. The accept half is `nip42.authRequired` +(`src/handlers/event-message-handler.ts:104`, defined at `:285-295`): the one publish +gate a setting selects, global rather than kind-scoped, and off by default +(`resources/default-settings.yaml:76`). §9 item 7 carries both. + +## 5. Registry changes with no relay-visible delta + +The other 22 NIPs from the delta window, in registry order. Each changed at the +cutoff (date and subject from the registry's own last commit touching that file) and +none of the changes reaches a relay. Rows here carry `Delta: none`. + +- **NIP-02** — 2026-09-20, *"nip02: clarify petname character eligibility."* The + change adds a character rule for petname resolution (ASCII letters, digits and `_`) + to the `nostr:` identifier examples; a relay stores kind 3 unchanged. +- **NIP-03** — 2026-05-31, *"add unrecommended tag and warning to NIP bodies."* A + header tag and warning banner; the kind-1040 attestation shape is untouched, and + nostream's `TimestampEventStrategy` needs no change. +- **NIP-04** — 2025-12-23, *"add relay tag (#2173)"*. The commit adds `relay` to the + NIP's own role tags, a registry annotation rather than protocol text. The message + format is unchanged; a relay stores kind 4 as opaque content. +- **NIP-05** — 2026-06-13, *```` ```jsonc -> ```yaml everywhere. ````* A fence-language sweep + across 45 files; no requirement changed, so nostream's admission-time verification + (`src/handlers/event-message-handler.ts:543-590`) is unaffected. +- **NIP-09** — 2026-06-13, *```` ```jsonc -> ```yaml everywhere. ````* Formatting only; + deletion semantics are unchanged. nostream's `DeleteEventStrategy` implements the + `e`-tag half only — it collects valid `e` tags and calls `deleteByPubkeyAndIds` + (`src/handlers/event-strategies/delete-event-strategy.ts:22-31`), so an `a`-only + request leaves its targets untouched. That gap is a Phase 2 item (§9.12). +- **NIP-13** — 2025-12-23, *"add relay tag (#2173)"*. Registry annotation only; + proof-of-work is still defined by leading zero bits on the id. +- **NIP-17** — 2026-06-13, *```` ```jsonc -> ```yaml everywhere. ````* Formatting only; the + kind-1059 envelope nostream handles is unchanged. +- **NIP-25** — 2026-06-13, *```` ```jsonc -> ```yaml everywhere. ````* Formatting only; kind 7 + reactions are unchanged. +- **NIP-28** — 2026-06-13, *```` ```jsonc -> ```yaml everywhere. ````* Formatting only; kinds + 40-49 are unchanged. +- **NIP-33** — 2024-08-20, *"rename "parameterized replaceable event" to "addressable + event" (#1418)"*. A rename across 17 files. nostream's + `isParameterizedReplaceableEvent` (`src/utils/event.ts:220-224`) and + `ParameterizedReplaceableEventStrategy` implement the same semantics under the old + name; the relay behaviour does not change. +- **NIP-40** — 2025-12-23, *"add relay tag (#2173)"*. Registry annotation only; the + `expiration` tag and its handling are unchanged. +- **NIP-45** — 2026-02-06, *"nip45: add hyperloglog relay response (#1561)"*. The + change adds an **optional** response shape: `approximate` and `"hll": ""` plus + the register algorithm, each introduced with "Relays may return". nostream returns + `{"count": }` from `src/handlers/count-message-handler.ts:45-46`, which + stays conformant; HLL is recorded in §9 as an optional future capability. +- **NIP-46** — 2026-07-15, *"Avoid nip46 silent timeouts (#2375)"*. A client/signer + protocol; relays only store the kind-24133 messages it defines, generically. +- **NIP-47** — 2026-08-01, *"NIP-47: simplify core spec and introduce extensions + (#2419)"*. A client/wallet protocol. nostream's NWC use + (`src/payments-processors/nwc-payments-processor.ts`) is an operator payment client, + not a relay requirement. +- **NIP-50** — 2026-06-13, *```` ```jsonc -> ```yaml everywhere. ````* Formatting only; the + `search` filter is unchanged. +- **NIP-56** — 2026-06-13, *```` ```jsonc -> ```yaml everywhere. ````* Formatting only; kind + 1984 reports are unchanged. +- **NIP-59** — 2026-06-02, *'Revert ""id"s not required in NIP-59 rumors."'* The revert + puts the `id` field back on rumors, which is a client-side rule for the unsigned + inner event; a relay sees only the outer kind-1059 gift wrap. +- **NIP-65** — 2026-06-13, *```` ```jsonc -> ```yaml everywhere. ````* Formatting only; kind + 10002 is unchanged. +- **NIP-66** — 2026-05-28, *"fix: sanity."* This retitled `66.md` from *Relay Discovery + and Liveness Monitoring* to *Relay Liveness Monitoring* and fixed the heading + underline; no requirement changed. +- **NIP-70** — 2026-06-13, *```` ```jsonc -> ```yaml everywhere. ````* Formatting only; the + `"-"` tag and the publish rules are unchanged. +- **NIP-89** — 2026-06-16, *"Add aggregate hash and site snapshots to NIP-5A (#2287)"*. + The commit's `89.md` change adds optional `latest` and `next` tags to web-app + descriptors (kind 31990), pointing at NIP-5A site manifests. A relay stores 31990 + generically; nothing in the change asks a relay to read the new tags. +- **NIP-90** — 2026-06-13, *```` ```jsonc -> ```yaml everywhere. ````* Formatting only; DVM + job kinds 5000-6999 are unchanged. + +## 6. The 24 post-capture commits + +Mechanical comparison, not reading: `git log --oneline 242a3001..041fe34e` lists 24 +commits, and `git diff --stat 242a3001..041fe34e -- src/ resources/ migrations/` +reports 21 files changed, 671 insertions, 12 deletions. + +Four commits touch protocol code: + +| commit | subject | what it did | +| --- | --- | --- | +| `7409006` | feat(nip77): add NEG-OPEN/NEG-MSG/NEG-CLOSE wire protocol (#818) | the NIP-77 implementation: three handlers, `src/utils/negentropy.ts`, `src/utils/negentropy-sessions.ts`, `src/utils/nip77.ts`, schemas, settings | +| `f0bcdcc` | feat(nip77): advertise NIP-77 in NIP-11 and expose its settings in the admin console (#830) | adds 77 to `package.json`'s `supportedNips` and filters it at serve time (`root-request-handler.ts:94-96`); admin settings schema | +| `48fe9c4` | fix(settings): validate info.relay_url is a usable absolute URL (#825) | `src/utils/settings-config.ts`; protects the `payments_url`/`relay_url` values NIP-11 depends on | +| `50ffe33` | fix(deps): resolve open dependabot security alerts (#838) | dependency bumps. Its one `src/` change is an import-shape fix in `src/services/event-import-service.ts` (`stream-json` default export), not protocol behaviour | + +The remaining **20 commits touch no `src/**` file**: website work (`82287d4`, +`bb28deb`, `b2a997d`, `4642dfd`, `91a5a7e`, `72fca58`, `c352d72`, `1b1ef4f`, +`2234a25`, `ee7e06b`), release chores (`d4e894d`, `9bf130b`, `4f6579d`, `ed74856`, +`3877140`, `d31f4f8`, `d3651dc`, `4d7d0d0`), CI (`13584bf`), and README copy (`041fe34`). +Checked +per commit with `git show --name-only --format= | grep -cE '^(src|resources|migrations)/'`, +which returns 0 for each of the 20. + +## 7. Advertised without NIP-specific code, implemented without advertisement + +**Advertised with no NIP-specific code of its own** — NIP-04, NIP-14, NIP-22, NIP-28, +and NIP-15: + +| NIP | what exists | why it is not NIP-specific work | +| --- | --- | --- | +| 04 | kind constant `src/constants/base.ts:6`; in `DEFAULT_RESTRICTED_READ_KINDS` `src/utils/nip42.ts:10-13` | encryption is client-side; the relay stores and serves kind 4. The only relay-visible behaviour is the restricted-read default, and that default applies only when `nip42.restrictedReads.enabled` is on — off by default (§4) | +| 14 | tag constant `src/constants/base.ts:92` | a `subject` tag is stored with the event; no code path reads it | +| 22 | `created_at` bounds `src/handlers/event-message-handler.ts:204-216`, advertised at `src/handlers/request-handlers/root-request-handler.ts:138-139` | the number now means Comment (§4); the limits are NIP-11's behaviour, moved there before `22.md` was deleted (§4) | +| 28 | kinds 40-49 `src/constants/base.ts:20-26`; kind 41 is in `isReplaceableEvent` (`src/utils/event.ts:203-210`) | `grep -rl 'NIP-28' src/` returns nothing and `event-strategy-factory.ts` has no channel branch, but kind 41 (channel metadata) is routed to `ReplaceableEventStrategy` by that predicate; kinds 40 and 42-49 fall to `DefaultEventStrategy` | +| 15 | `// NIP-15` on the EOSE builder, `src/utils/messages.ts:27-30` | the registry's 15 is Marketplace (§4); the comment names the old EOSE NIP | + +Four advertised NIPs are **tombstones** whose text moved into NIP-01: NIP-12, NIP-16 +and NIP-20 (*"Moved to NIP-01 (01.md)."*) and NIP-33 (*'Renamed to "Addressable +events" and moved to NIP-01 (01.md).'*). nostream implements the behaviour and +advertises the numbers; the behaviour is what the rewrite must keep (§9). + +**Implemented but not advertised** — nine NIPs, all live code: + +| NIP | implementation | advertised? | +| --- | --- | --- | +| 05 | `src/utils/nip05.ts` (220 lines), `src/repositories/nip05-verification-repository.ts`, admission gate `src/handlers/event-message-handler.ts:543-590`, re-verification `src/app/maintenance-worker.ts:264-277` | no; README says yes (`README.md:73`) | +| 56 | `src/utils/nip56.ts` (102 lines), `src/handlers/event-strategies/report-event-strategy.ts`, kind 1984 `src/constants/base.ts:36`, hide-from-reads `src/repositories/event-repository.ts:221-232` | no, and absent from the README list | +| 59 | kind 1059 `src/constants/base.ts:32`; seal/direct-message admission block `src/handlers/event-message-handler.ts:347-355` | no | +| 62 | `VanishEventStrategy` `src/handlers/event-strategies/vanish-event-strategy.ts:18-30`, kind 62 `src/constants/base.ts:18`, `isRequestToVanishEvent` `src/utils/event.ts:230-244`, vanished-pubkey block `src/handlers/event-message-handler.ts:360-375` | no; README says yes (`README.md:93`) | +| 66 | `src/utils/nip66-events.ts`, `src/utils/nip66-nip11-tags.ts`, `src/utils/nip66-schedule.ts`, `src/services/nip66-event-publisher.ts`, `src/app/relay-monitor-worker.ts` | no — and `src/utils/network-health-mismatches.ts:64-74` raises `nip66-not-advertised` against *other* relays for exactly this | +| 70 | `isProtectedEvent` `src/utils/event.ts:321-323`, `EventTags.Protected` `src/constants/base.ts:111`, enforcement `src/handlers/event-message-handler.ts:299-337` | no | +| 89 | kinds 31989/31990 `src/constants/base.ts:81-82`, `src/app/dvm-orchestrator-worker.ts` | no | +| 90 | DVM ranges `src/constants/base.ts:47-51`, `src/handlers/event-strategies/dvm-job-request-event-strategy.ts`, `src/repositories/dvm-job-repository.ts`, `src/app/dvm-orchestrator-worker.ts` | no | +| 98 | `src/utils/nip98.ts` (281 lines), `src/utils/nip98-replay.ts`, gate `src/handlers/request-handlers/admin-auth-middleware.ts:41-100`, tags `src/constants/base.ts:115-118` | no | + +NIP-89's implementation is deliberately passive — the two addressable kinds are +accepted and served, and `test/integration/features/nip-89/nip-89.feature` proves it +(*"NIP-89 Recommended Application Handlers (passive accept-and-serve)"*). NIP-62 is +the other row worth naming: its code is live +(`src/factories/event-strategy-factory.ts:56` dispatches to it before every other +strategy) while `package.json:7` does not list it. + +**The README and `package.json` disagree in both directions** at 3.5.1. `README.md:69-95` lists +NIP-05 (`:73`), NIP-26 (`:84`, rendered `- [ ] … (removed upstream; not implemented)`) +and NIP-62 (`:93`), none of which `supportedNips` carries; and it omits NIP-17, which +`package.json:7` carries. It also spells the two number collisions out in its own +words: `README.md:79` *"NIP-15: End of Stored Events Notice"* and `README.md:82` +*"NIP-22: Event `created_at` Limits"*. The `Advertised` column comes from +`package.json:7` only, never from the README. + +## 8. NIP-11, field by field + +`11.md` at the cutoff defines **fourteen** top-level keys: the eleven of +`### Name` through `### Terms of Service` (`name`, `description`, `banner`, `icon`, +`pubkey`, `self`, `contact`, `supported_nips`, `software`, `version`, +`terms_of_service`), plus `limitation` (`### Server Limitations`), `payments_url` and +`fees` (`### Pay-to-Relay`). nostream emits **all fourteen** +(`src/handlers/request-handlers/root-request-handler.ts:83-161`), four of them +conditionally: `banner` (`:86`), `icon` (`:87`), `self` (`:89`, derived by +`resolveRelaySelfPubkey` (`src/utils/nip43.ts:77`) at `:81` when unset) and +`terms_of_service` (`:101`). + +The reverse direction is where the extras are. Six names nostream emits occur **zero +times** in `11.md` at the cutoff (`grep -c` for each is 0): + +| key | where nostream emits it | registry history | +| --- | --- | --- | +| `limitation.max_filters` | `root-request-handler.ts:105` | removed by `611b635` (2025-04-13) | +| `limitation.max_filter_values` | `:109` | never defined | +| `limitation.min_prefix` | `:112` | removed by `89915e0` (2023-08-20) | +| `limitation.search_supported` | `:142` | never defined | +| `supported_nip_extensions` | `:97` | never defined | +| `supported_mips` | `:98` | never defined; Marmot's registry | + +Two behaviours behind those fields are worth stating precisely, because they read as +contradictions and are not: + +- **`auth_required` is hard-coded `false`** (`root-request-handler.ts:136`) while the + relay can require AUTH to publish. That is deliberate and matches the spec's split: + `11.md` §"Server Limitations" defines `auth_required` as authentication before any + action, and `restricted_writes` as *"some kind of condition to be fulfilled to accept + events (not necessarily, but including `payment_required` and `min_pow_difficulty`)"* + (`11.md:151-154`). The publish-only + condition is computed by `hasWriteRestriction` (`:63-74`) and advertised as + `restricted_writes` (`:141`). A client is correctly told "you may read without + auth, you may not write without it". +- **`search_supported` tracks `settings.nip50.enabled`** (`:142`) while NIP-50 stays + in the static `supported_nips` list. With NIP-50 disabled the document says both + "50 is supported" and "search is not supported". The spec does not define + `search_supported`, so this is an internal inconsistency rather than a violation; + §9 item 11 records it as a decision for the rewrite. + +A third field is advertised but not enforced. `limitation.max_event_tags` is the +literal `2500` (`root-request-handler.ts:113`), and `11.md:135` gives the field meaning +— *"the maximum number of elements in the `tags` list"* — while nothing checks it: the +event schema puts no maximum on `tags` (`src/schemas/event-schema.ts:37`) and no +admission path counts them. The only ceiling is the transport's +`network.maxPayloadSize` of 524288 bytes (`resources/default-settings.yaml:161`, +handed to the websocket at `src/factories/worker-factory.ts:75`). A rewrite that carries +`2500` forward should decide to enforce it, or it is advertising a limit it does not +keep. + +## 9. Open items for Phase 2 + +This job files no issues: the dispositions above are the deliverable, and Phase 2 +turns them into work. The items, in the order they affect the rewrite: + +1. **The advertisement set needs a decision, not a copy.** Nine live features are + unadvertised (NIP-05, 56, 59, 62, 66, 70, 89, 90, 98), five advertised numbers have + no code for the NIP they now name (04, 14, 15, 22, 28), and four more are + advertised tombstones whose text now lives in NIP-01 (12, 16, 20, 33). The answer + differs by group — keep 12/16/20/33 or drop them, preserve 04/14/28's generic + behaviour, decide 15 and 22 in item 2, name the nine — but it should be one list + that matches what the relay does, and `README.md:69-95` should stop contradicting + `package.json:7` in both directions. +2. **NIP-15 and NIP-22 are number collisions.** `README.md:79` and `:82` name the old + NIPs. Either the relay drops those numbers from `supported_nips` (NIP-15 has no + relay duty; NIP-22's kind 1111 is client-side) or it implements the current texts. + Implementing NIP-22's tag rules is the larger of the two. +3. **NIP-42 / NIP-67: the `auth` hint.** One change closes both: emit the third + `EOSE` element with `"auth"` where restricted kinds were trimmed (and `"finish"` / + `"more"` where the limit was reached), and add 67 to `supported_nips`. This is the + `opportunistic` disposition on 67 and the narrow gap in §4's NIP-42. +4. **NIP-43 / NIP-86: the invite flow.** `43.md` now delegates claims to NIP-86's + `createclaim`. The rewrite either exposes that method (which also covers the + NIP-86 disposition) or keeps the removed kind-28935 `REQ` path and says so. If it + exposes it, the method carries NIP-86's own auth model — a NIP-98 event in + `Authorization` on every method (`86.md:255-259`) — and must stay behind + `admin.nip98.allowedPubkeys` (`src/handlers/request-handlers/admin-auth-middleware.ts:41-100`); + as a public route it would mint invite codes for anyone. +5. **NIP-44's extended length.** Adopt the 6-byte prefix and the raised cap, or the + relay keeps rejecting valid large gift wraps at admission. This is a prerequisite + for NIP-17 parity, not a separate feature. The 4 GiB figure is the format's ceiling, + not the relay's: `network.maxPayloadSize` (524288, + `resources/default-settings.yaml:161`) still caps what arrives, so the raise is a + format change, not a bigger buffer. +6. **NIP-62's deletion.** Replace the soft delete for vanish requests with a real + delete, and remove gift wraps that p-tag the vanished pubkey. +7. **NIP-78's AUTH guard — two halves, two decisions.** The serving half: set + `nip42.restrictedReads` to `enabled: true` with `kinds: [78, 30078]`, or take the + broader `[4, 1059, 78, 30078]` — which a relay already running `enabled: true` with + the shipped list must, since the narrow form would drop the protection 4 and 1059 + have today — and record what the choice does to DM and gift-wrap reads. Land item 3 + with either: once any kind is trimmed, the missing `auth` hint is the silence §4 + describes. The flag is `enabled` at `resources/default-settings.yaml:83` and the + shipped kinds list is `:84-86`; `getRestrictedReadKinds` returns the configured list + when it is set (`src/utils/nip42.ts:23`), so "add 78 and 30078" means replacing that + list, and adding kinds to a list the guard never reads changes nothing (§4). It + closes unauthenticated serving only for reads that **name** the kinds: `78.md:23`'s + owner rule is not met (the guard admits any authenticated p-tagged pubkey, `:29-41`), + and a COUNT that names no kinds still counts (§4). Both are the rewrite's to close — + the owner rule per kind, because tightening that predicate also denies kind-4/1059 + recipients, and the COUNT beside item 3, which designs the read guards. The accept + half: the only AUTH gate a setting selects, and the only one that applies to every + publish, is `nip42.authRequired` (`src/handlers/event-message-handler.ts:104`, + defined at `:285-295`), global rather than kind-scoped and off by default + (`resources/default-settings.yaml:76`). Turning it on makes every publisher AUTH as + the event's author, so clients without NIP-42 AUTH stop publishing — every publish by + a normal client, since the relay's own pubkey is exempt (`:290-292`) and relay-signed + invite and monitor events keep flowing. NIP-70's protected-event check (`:111`, + `:299-304`) is the existing precedent for the cheaper alternative: a per-event + AUTH-as-author test that fires only when the `-` marker is present, so a kind test + beside it would cover 78 and 30078 without new machinery. The other write-side + refusals — admission (`isUserAdmitted`, `:125`), NIP-05 (`:132`), content limits, PoW + and lists (`canAcceptEvent`, `:97`), rate limits (`:86`) and vanish blocks (`:118`) — + are policy, not AUTH, and a port carries them unchanged. +8. **NIP-01's prefix matching.** Keep it or move to exact 64-character values as the + current text requires; whether `min_prefix` stays advertised is item 11's decision, + per key. Keeping it is not free: the prefix arms are `substring(...)` predicates + that no index on `event_id` or `event_pubkey` can serve + (`src/repositories/event-repository.ts:278-288`), so a prefix-only filter scans + `events`. The rewrite can keep the extension cheaply by making a prefix a range seek + in its key layout; otherwise the honest choice is exact values. Either way the + decision should be written down, because today it is an undocumented extension. +9. **NIP-45's HyperLogLog response** is optional under the cutoff text. Take it when + `COUNT` is next touched, not before — and set the exact-versus-approximate policy + at the same time, because HLL still visits every matching event to fill its + registers; its leverage is the deterministic `offset` and cross-query caching, not + a cheaper single pass. +10. **NIP-29 and NIP-96 are out of scope** until the rewrite takes on relay groups or + a file-storage service; neither exists in nostream today. +11. **NIP-11's field set.** Four keys in `limitation` are not in `11.md` today + (`max_filters` and `min_prefix` were removed, `max_filter_values` and + `search_supported` were never defined) and two top-level keys never were + (`supported_nip_extensions`, `supported_mips`). Decide per key whether to keep it, + rename it into an extension object or drop it, and settle `search_supported` + against `supported_nips` so the document stops saying both "50 is supported" and + "search is off" (§8). Decide `limitation.max_event_tags` too: `2500` is advertised + and not enforced. `min_prefix` follows item 8's answer: keep it only if prefix + matching is kept. Whatever survives needs a test. `max_filters` is checked by the + NIP-11 integration feature (`test/integration/features/nip-11/nip-11.feature:30-33`), + and `max_filter_values` and both `search_supported` cases by the root-handler unit + tests (`test/unit/handlers/request-handlers/root-request-handler.spec.ts:137,310-326`); + the four untested keys are `min_prefix`, `supported_nip_extensions`, `supported_mips` + and `max_event_tags` (§1). NIP-78's two kinds have no test either, and item 7 + carries that pair. +12. **NIP-09 `a`-tag deletion.** `DeleteEventStrategy` accepts only `e` tags + (`src/handlers/event-strategies/delete-event-strategy.ts:22-31`); a kind-5 event + whose tags are `a`-only deletes nothing. The rewrite either implements + addressable-event deletion or records the omission deliberately — but §5 and the + §7 row now say so, and Phase 2 must not inherit the silence. diff --git a/migrations/20261016_120000_create_user_subscriptions_table.js b/migrations/20261016_120000_create_user_subscriptions_table.js new file mode 100644 index 000000000..9494da565 --- /dev/null +++ b/migrations/20261016_120000_create_user_subscriptions_table.js @@ -0,0 +1,45 @@ +exports.up = async function (knex) { + await knex.schema.createTable('user_subscriptions', (table) => { + // One subscription per pubkey, so the pubkey is the key. + table.binary('pubkey').primary().references('pubkey').inTable('users').onDelete('CASCADE') + table.text('plan_id').notNullable() + table.enum('status', ['active', 'renewal_pending', 'past_due', 'expired']).notNullable().defaultTo('active') + table.timestamp('current_period_start', { useTz: true }).notNullable() + // NULL means the subscription never ends (the grandfathered plan). + table.timestamp('current_period_end', { useTz: true }).nullable() + table.timestamp('grace_ends_at', { useTz: true }).nullable() + table.boolean('cancel_at_period_end').notNullable().defaultTo(false) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + + // The renewal and expiry sweeps filter by status and order by the period or grace end. + table.index(['status', 'current_period_end'], 'idx_user_subscriptions_status_period_end') + table.index(['status', 'grace_ends_at'], 'idx_user_subscriptions_status_grace_end') + }) + + await knex.schema.alterTable('invoices', (table) => { + table.text('plan_id').nullable() + table.timestamp('period_start', { useTz: true }).nullable() + table.timestamp('period_end', { useTz: true }).nullable() + }) + + // Everyone admitted so far keeps access without a paid period: they move onto a plan that never + // expires, so turning subscription plans on later cannot lock anyone out. + await knex.raw(` + INSERT INTO user_subscriptions (pubkey, plan_id, status, current_period_start, current_period_end) + SELECT pubkey, 'grandfathered', 'active', COALESCE(tos_accepted_at AT TIME ZONE 'UTC', created_at), NULL + FROM users + WHERE is_admitted = true + ON CONFLICT (pubkey) DO NOTHING + `) +} + +exports.down = async function (knex) { + await knex.schema.alterTable('invoices', (table) => { + table.dropColumn('plan_id') + table.dropColumn('period_start') + table.dropColumn('period_end') + }) + + await knex.schema.dropTable('user_subscriptions') +} diff --git a/migrations/20261025_120000_add_moderation_stats_first_seen_indexes.js b/migrations/20261025_120000_add_moderation_stats_first_seen_indexes.js new file mode 100644 index 000000000..f6d7a3db5 --- /dev/null +++ b/migrations/20261025_120000_add_moderation_stats_first_seen_indexes.js @@ -0,0 +1,59 @@ +/** + * Supports admin moderation activity stats (queryTopPublishingPubkeys / + * queryTopClientIps), which filter live events by relay receipt time: + * + * WHERE deleted_at IS NULL AND first_seen >= ? + * + * Partial indexes keep the working set small on relays where most rows are + * soft-deleted history. CREATE INDEX CONCURRENTLY cannot run inside a + * transaction. + */ + +exports.config = { transaction: false } + +const INDEX_NAMES = [ + 'events_live_first_seen_pubkey_idx', + 'events_live_first_seen_remote_address_idx', +] + +// A failed concurrent build leaves the index behind as invalid. `IF NOT EXISTS` +// would then skip it on retry, leaving the migration successful but the index +// unusable. Drop any invalid leftover before recreating it. +const dropInvalidIndex = async (knex, indexName) => { + const result = await knex.raw( + ` + SELECT 1 + FROM pg_index + WHERE indexrelid = to_regclass(?) + AND NOT indisvalid + `, + [indexName], + ) + + if (result.rows.length > 0) { + await knex.raw('DROP INDEX CONCURRENTLY IF EXISTS ??', [indexName]) + } +} + +exports.up = async function (knex) { + for (const indexName of INDEX_NAMES) { + await dropInvalidIndex(knex, indexName) + } + + await knex.raw(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS events_live_first_seen_pubkey_idx + ON events (first_seen, event_pubkey) + WHERE deleted_at IS NULL + `) + + await knex.raw(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS events_live_first_seen_remote_address_idx + ON events (first_seen, remote_address) + WHERE deleted_at IS NULL AND remote_address IS NOT NULL + `) +} + +exports.down = async function (knex) { + await knex.raw('DROP INDEX CONCURRENTLY IF EXISTS events_live_first_seen_remote_address_idx') + await knex.raw('DROP INDEX CONCURRENTLY IF EXISTS events_live_first_seen_pubkey_idx') +} diff --git a/package.json b/package.json index 050fa91b9..754f439be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nostream", - "version": "3.4.0", + "version": "3.11.0", "packageManager": "pnpm@10.33.0", "private": true, "description": "A Nostr relay written in Typescript.", @@ -28,7 +28,8 @@ 44, 45, 50, - 65 + 65, + 77 ], "supportedNipExtensions": [], "supportedMips": [ @@ -174,19 +175,19 @@ "@opentelemetry/exporter-metrics-otlp-http": "^0.219.0", "@opentelemetry/resources": "^2.8.0", "@opentelemetry/sdk-metrics": "^2.8.0", - "axios": "^1.18.0", + "axios": "^1.20.0", "cac": "^7.0.0", "colorette": "^2.0.20", "express": "4.22.1", - "js-yaml": "4.2.0", - "knex": "2.4.2", + "js-yaml": "4.3.2", + "knex": "3.2.9", "ora": "^9.3.0", "pg": "8.9.0", "pg-query-stream": "4.3.0", "pino": "^8.21.0", "ramda": "0.28.0", "redis": "4.5.1", - "stream-json": "^2.1.0", + "stream-json": "^3.7.0", "ws": "^8.21.0", "zod": "^3.22.4" }, @@ -195,7 +196,12 @@ }, "pnpm": { "overrides": { - "serialize-javascript": ">=7.0.3 <8" + "serialize-javascript": ">=7.0.3 <8", + "proxy-addr@<2.0.8": ">=2.0.8", + "fast-uri@<3.1.7": ">=3.1.7", + "brace-expansion@<1.1.21": ">=1.1.21 <2", + "brace-expansion@>=2.0.0 <2.1.7": ">=2.1.7 <3", + "js-yaml@>=3.0.0 <3.15.2": ">=3.15.2 <4" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbfa52202..e96d47cf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,11 @@ settings: overrides: serialize-javascript: '>=7.0.3 <8' + proxy-addr@<2.0.8: '>=2.0.8' + fast-uri@<3.1.7: '>=3.1.7' + brace-expansion@<1.1.21: '>=1.1.21 <2' + brace-expansion@>=2.0.0 <2.1.7: '>=2.1.7 <3' + js-yaml@>=3.0.0 <3.15.2: '>=3.15.2 <4' importers: @@ -33,8 +38,8 @@ importers: specifier: ^2.8.0 version: 2.8.0(@opentelemetry/api@1.9.1) axios: - specifier: ^1.18.0 - version: 1.18.0 + specifier: ^1.20.0 + version: 1.20.0 cac: specifier: ^7.0.0 version: 7.0.0 @@ -45,11 +50,11 @@ importers: specifier: 4.22.1 version: 4.22.1 js-yaml: - specifier: 4.2.0 - version: 4.2.0 + specifier: 4.3.2 + version: 4.3.2 knex: - specifier: 2.4.2 - version: 2.4.2(pg@8.9.0) + specifier: 3.2.9 + version: 3.2.9(pg-query-stream@4.3.0(pg@8.9.0))(pg@8.9.0) ora: specifier: ^9.3.0 version: 9.4.0 @@ -69,8 +74,8 @@ importers: specifier: 4.5.1 version: 4.5.1 stream-json: - specifier: ^2.1.0 - version: 2.1.0 + specifier: ^3.7.0 + version: 3.7.0 ws: specifier: ^8.21.0 version: 8.21.0 @@ -191,8 +196,149 @@ importers: specifier: ^8.0.6 version: 8.0.6 + website/site: + dependencies: + '@astrojs/markdown-remark': + specifier: ^7.3.5 + version: 7.3.5 + '@fontsource-variable/inter': + specifier: ^5.2.5 + version: 5.3.0 + '@fontsource/jetbrains-mono': + specifier: ^5.2.5 + version: 5.3.0 + astro: + specifier: ^7.4.4 + version: 7.4.4(@astrojs/markdown-remark@7.3.5)(@types/node@24.12.2)(esbuild@0.28.2)(jiti@1.21.0)(yaml@2.9.1) + sharp: + specifier: ^0.35.5 + version: 0.35.5(@types/node@24.12.2) + devDependencies: + '@astrojs/check': + specifier: ^0.9.4 + version: 0.9.10(@emnapi/runtime@1.11.3)(prettier@3.9.10)(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + wrangler: + specifier: ^4.151.0 + version: 4.151.0(@types/node@24.12.2) + packages: + '@astrojs/astro2tsx@0.1.3': + resolution: {integrity: sha512-GMDC7Eo0myBUA/QLBl0u+OcegJ8LiBhccmNZR34KmprQNP8gQUJlWlOOZWUsxNA2rNS6GuPYcGDyLoPnjmn4/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/check@0.9.10': + resolution: {integrity: sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + + '@astrojs/compiler-binding-android-arm64@0.5.2': + resolution: {integrity: sha512-kckIxon6Rm/Bxzh9mGPYpRRqUbToUQoHptCmpMmE+Yt0Xumq8CqrKUidyYhrBsrHVO7bxuRzzWUUqvpf+iYceQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@astrojs/compiler-binding-darwin-arm64@0.5.2': + resolution: {integrity: sha512-1ZG5Ar3dD8HtodX9XH1mRmE0qBXZlvjYXSFfpgFwocqfWnvhFuqtGUNseL8wgfImiRMztIjpQqlogQFQQtgiag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.5.2': + resolution: {integrity: sha512-+kl35S7hiHJ2cb7eujwEsPFYYuSgE11uEE2Z20lAJ+undpgulMcW+weQqFPOSSZccEKHOiLIY+kQHkb4e4092w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.2': + resolution: {integrity: sha512-BqlR0EHNQLPLwgL9+xogjsAc7zGf0cHho2CPdhv0P1yqbVMwGVaGAqrANP6VYAXCqSC43AF4Czcxc2n+oqdesg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.2': + resolution: {integrity: sha512-GRGyby3AZWw5d6JQzidyxPyvGgHWsSusrpgKyFVpDTKctABF7L3Pd5V1cRJbV36/rcu+Eh292/wL7pF7MkmGkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.2': + resolution: {integrity: sha512-9XAqGTMQAtIslMsjK3xdyY6OI9d59OZqcLBB6pvD/DcoR0daYswfrKx2azdDERDY4AqAnWPC7odVM1BmxYv3Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-x64-musl@0.5.2': + resolution: {integrity: sha512-jOViewGeTh9bSfS8vK72ECxoy2yvmA+Pd/ZcEvBI5c7+7IcfHwZ5qA4L9fRYuOhSrAifvQ2dV4vm1XAtloaWBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-wasm32-wasi@0.5.2': + resolution: {integrity: sha512-Z7p64doar1uvR5C9Xs6j1CV9lGlPVQX7OQ2aAtZ/xKC5aGS6GB13nyc2UfQXTQLvZrvVwsyaflTQy6ziY5Bekw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.2': + resolution: {integrity: sha512-Tr13bh+ahTWsjXjzctK7pFpWV+dUU4FtBfETlR7gO+ueex0F+RDch7eRr0YfKysaRhk9tWm14lsCJ5uWyFPNHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.2': + resolution: {integrity: sha512-SfKkI+jOQYLh6wojPvVPV0lFfQnxisweN9B+qCzRvZp7DHAuGif8Y0WcC5PUgN4jb8VKy57LggQDtggR46Du1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding@0.5.2': + resolution: {integrity: sha512-lEn76+0JcMQ5RDXU0wSMWJg2ZppiZvSULpghC29cfv4o9bhvWFDbQjsCtvjjXN9iVLFQ7fL3wTR2L2UU13mP8g==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-rs@0.5.2': + resolution: {integrity: sha512-IvwYLB8t1M86rKO4oPdBi9GP4gzXcgrVlNgocjwos1jeeUuEIV8d9Crn1PvYD/5IKteWOxpIHomf7e8iYlkE+Q==} + engines: {node: '>=22.12.0'} + + '@astrojs/internal-helpers@0.13.0': + resolution: {integrity: sha512-20jxQqnSmpskcGyad7N5l6Xf1gz0UCa6lNde44bEBdaXGpncEYfAZJ142r2ZgzBaG4dUyzGmhqZWv1INU2eiHQ==} + + '@astrojs/language-server@2.17.2': + resolution: {integrity: sha512-WzqCBYo0RtKrkPLPMnTz2+UOQPosaFhoG/PvoSyPIgBU0BKeS6qeaYv7Rscq7P1LxJdoN0VosQF2h2TZTsIGHg==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-remark@7.3.5': + resolution: {integrity: sha512-Xk0gvOMEpBul2zj5jZXS5WH2Gng/6dW7c3WWLXgip+LZGpY64frwTvBChznMtpnNIqVgFXSsu44RYrISojcodQ==} + + '@astrojs/markdown-satteri@0.4.6': + resolution: {integrity: sha512-6biKp2lEAYycF3jhv91br+Otyf4EgDFkF1oRL9ks0u/m/YRGmI7XkGQ2MgaM35DslT03f7NtozKtlqfBNkp6bw==} + + '@astrojs/prism@4.1.2': + resolution: {integrity: sha512-jMrUEpFDarDO1KWPvVc9eSa8DSfwBlwEOHwX+8zvZQpiqXRGDxFnOXHsYqV6DP7w+0qIgTr98gS1GdQlm8ypWg==} + engines: {node: '>=22.23.4'} + + '@astrojs/telemetry@3.4.2': + resolution: {integrity: sha512-2JOMRUxJrBSfiVDfeKLv1wBmvSIx1Y6u2YEO8awZ9qSq3u/rNAiUyFdOYi8dHv91kHrmYEQX7pRDw0J4xyj3UA==} + engines: {node: '>=22.23.4'} + + '@astrojs/yaml2ts@0.2.4': + resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -231,10 +377,18 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -248,6 +402,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -264,6 +423,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@biomejs/biome@2.4.12': resolution: {integrity: sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA==} engines: {node: '>=14.21.3'} @@ -321,6 +484,59 @@ packages: cpu: [x64] os: [win32] + '@bruits/satteri-darwin-arm64@0.10.5': + resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.10.5': + resolution: {integrity: sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + resolution: {integrity: sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-arm64-musl@0.10.5': + resolution: {integrity: sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@bruits/satteri-linux-x64-gnu@0.10.5': + resolution: {integrity: sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-x64-musl@0.10.5': + resolution: {integrity: sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@bruits/satteri-wasm32-wasi@0.10.5': + resolution: {integrity: sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + resolution: {integrity: sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==} + cpu: [arm64] + os: [win32] + + '@bruits/satteri-win32-x64-msvc@0.10.5': + resolution: {integrity: sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==} + cpu: [x64] + os: [win32] + + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -385,9 +601,60 @@ packages: '@clack/core@1.2.0': resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + '@clack/prompts@1.2.0': resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20261012.1': + resolution: {integrity: sha512-dkTL9aY3V4D40b+jBpGPCistWUPFQ/M61wlWi6i1o8MvHFFgEw7EQkcaYEv6Yd3bkPDD/3TFT4hRsMb2xVZSwQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20261012.1': + resolution: {integrity: sha512-2/nVGcKXjNNI+DhxKSepTKvFng9H7aN2WXv6Zu67iqgI6JrxypNt5kTPv4ZHosxrcfyKMci/2RfN7GIrN0BitQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20261012.1': + resolution: {integrity: sha512-uMe7ONHnLxStD1Ye0YFHBO36uj4cStv5P53rW+5GVWXFT8BRjqVixlH0IqCAHe1Pmcwyd1+7fVoGde89MZSCJg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20261012.1': + resolution: {integrity: sha512-I0VMdcXk7ygG5vYDce0ePTBFdnBXC0i9WiBY5mWiv0iTinbfea6/rHqSQ7B1UMpjgrYVs9E0m4bZ9ufsk5GCFA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20261012.1': + resolution: {integrity: sha512-c5hQvNx574UEoknaCFvMa/NdLrrYiALNbe6sx1okW8uOiRYWxrFuA8WpXFnBJ5XJypm0TEAHYaUTGv3rRLpY7Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -519,11 +786,212 @@ packages: '@cucumber/tag-expressions@6.0.0': resolution: {integrity: sha512-JbNb/254Wn6b8cfrIJoqR0NekHXvoB/eMvSY4RK11H8k+YZfm7mZesu/3yVX67nkW+Y+PGjZFcgTMcfjwFRsRw==} + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + '@ericcornelissen/bash-parser@0.5.3': resolution: {integrity: sha512-9Z0sGuXqf6En19qmwB0Syi1Mc8TYl756dNuuaYal9mrypKa0Jq/IX6aJfh6Rk2S3z66KBisWTqloDo7weYj4zg==} engines: {node: '>=4'} deprecated: Support for this package will stop 2025-12-31 + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + + '@fontsource/jetbrains-mono@5.3.0': + resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==} + '@getalby/lightning-tools@5.2.1': resolution: {integrity: sha512-dxOmJLJAh6qJ8rsbA5/Bwj7MSI9X3RkxxqmCedl5rfP+yKwNSdfu8i4EiCZN/tk2hNBJb8GHSCcPRNZfwfmEHg==} engines: {node: '>=14'} @@ -532,6 +1000,168 @@ packages: resolution: {integrity: sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==} engines: {node: '>=14'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -566,6 +1196,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -578,6 +1211,23 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@napi-rs/wasm-runtime@1.2.6': + resolution: {integrity: sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || 2.0.0-beta.2 + '@emnapi/runtime': ^1.7.1 || 2.0.0-beta.2 + '@noble/ciphers@0.5.3': resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} @@ -678,6 +1328,12 @@ packages: resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-project/types@0.156.0': + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -737,6 +1393,15 @@ packages: resolution: {integrity: sha512-VRX7E7pX92C0akCMYGzsTqJoOwQS7/8R40pAPK7smgaEpKeEgVThqnIXt+wPdseD5CzS7OzMaIWlT3WXr3O5rQ==} engines: {node: '>=16.14'} + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@redis/bloom@1.1.0': resolution: {integrity: sha512-9QovlxmpRtvxVbN0UBcv8WfdSMudNZZTFqCsnBszcQXqaZb/TVe30ScgGEO7u1EAIacTPAo7/oCYjYAxiHLanQ==} peerDependencies: @@ -766,6 +1431,105 @@ packages: peerDependencies: '@redis/client': ^1.0.0 + '@rolldown/binding-android-arm-eabi@1.2.16': + resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.16': + resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.16': + resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.16': + resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.16': + resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.16': + resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.16': + resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.16': + resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.16': + resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.16': + resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.16': + resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.16': + resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.16': + resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@scure/base@1.1.1': resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} @@ -775,6 +1539,41 @@ packages: '@scure/bip39@1.2.1': resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} + '@shikijs/core@4.6.0': + resolution: {integrity: sha512-bfd4OKgvoyXYiYmMRuleA656P4V7M/Du9lcTl38Ngm6KszMj7bq4J1IGZwSYR4c95Tv5UQALtYNWUwaI9UT3xw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.6.0': + resolution: {integrity: sha512-K0oqxFoL+kEMdz89mHLFO15Tsbv3kFZ6WR1cXZKMvLSc+oqaM00A6pyga1qSeXB22dP0tTiSApRcGBaKWsEccg==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.6.0': + resolution: {integrity: sha512-aggd6Q7SuJOXNnsUbfG2XesxBJKBMAw/PDTUo9PyRjMaaJJ2dNrI+q0C+25ARXAK9yLHAlwxc9oRT2u68YKZ7w==} + engines: {node: '>=20'} + + '@shikijs/langs@4.6.0': + resolution: {integrity: sha512-1yMEX3oCVV54LUq/ovfpRkDQoYDkcvjSWFFa/YLUDMiSoRzMi2uxLJYbv41ahT8Lu9lFMaa7hecd3Q39trqUyg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.6.0': + resolution: {integrity: sha512-c54+TQEo9ToqgfL/AU8FpjnV09+GA9Oa7M5ROkkXteZfp5XzpAJGXjjvZQFQioJOwm/w/6Z3lIzQstAkl8coag==} + engines: {node: '>=20'} + + '@shikijs/themes@4.6.0': + resolution: {integrity: sha512-qHAVAs80RiGD+IRuH9G+JPDOEc9A/kHsng4EzCUCwB9bLzW6Z0y9WETh2YyCcADJBmfrIaJ1Jp7KBNP6FHkgtQ==} + engines: {node: '>=20'} + + '@shikijs/types@4.6.0': + resolution: {integrity: sha512-+ENG1adsKt/X7cRYw1Nl7BEpBmPOjmQ7bR/eGmHuMlT8gtOFf3KEi5l31DJYyAWhW6Y6bjP1plU7FmaxWGENFw==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.1.0': + resolution: {integrity: sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + '@sinonjs/commons@2.0.0': resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} @@ -802,6 +1601,9 @@ packages: deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. hasBin: true + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@teppeis/multimaps@3.0.0': resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==} engines: {node: '>=14'} @@ -818,6 +1620,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -830,12 +1635,24 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@4.19.8': resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} '@types/express@4.17.21': resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -845,12 +1662,24 @@ packages: '@types/k6@1.7.0': resolution: {integrity: sha512-oL4mckVcOPIA2HUrCVj3aQXCJgCqsQe35Uc4fRTffmrQuR24v92GJImnagqUaRnC1TQVJFx85o3aHQPP+0bxpg==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} '@types/mocha@9.1.1': resolution: {integrity: sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -896,6 +1725,12 @@ packages: '@types/strip-json-comments@0.0.30': resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@9.0.1': resolution: {integrity: sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==} @@ -905,6 +1740,35 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@volar/kit@2.4.28': + resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/language-server@2.4.28': + resolution: {integrity: sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==} + + '@volar/language-service@2.4.28': + resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + '@zkochan/retry@0.2.0': resolution: {integrity: sha512-WhB+2B/ZPlW2Xy/kMJBrMbqecWXcbDDgn0K0wKBAgO2OlBTz1iLJrRWduo+DGGn0Akvz1Lu4Xvls7dJojximWw==} engines: {node: '>=10'} @@ -925,6 +1789,11 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -934,6 +1803,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -942,9 +1816,26 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + am-i-vibing@0.5.0: + resolution: {integrity: sha512-KLcsDhqf1XRrJmJr+9I/C0EX2K+sGrAGZFgkmd84c8TJO9zJBu+/kFInjazu1KfJWFhWDKdRWkpBzEs7s35DVA==} + hasBin: true + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -996,6 +1887,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + arity-n@1.0.4: resolution: {integrity: sha512-fExL2kFDC1Q2DUOx3whE/9KoN66IzkY4b4zUHUBFM1ojEYjZZYDcUW3bek/ufGionX9giIKDC5redH2IlGqcQQ==} @@ -1005,6 +1900,9 @@ packages: array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + array-last@1.3.0: resolution: {integrity: sha512-eOCut5rXlI6aCOS7Z7kCplKRKyiFQ6dHFBem4PwlwKeNFk2/XxTrhRh5T9PyaEWGy/NHTZWbY+nsZlNFJu9rYg==} engines: {node: '>=0.10.0'} @@ -1023,6 +1921,20 @@ packages: assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + astro@7.4.4: + resolution: {integrity: sha512-e+BzhGCaIY/H1tV/BQPTbOPZVZ8wuu6+bbpc+3oSMH2UgoB1l9peSGASk6nswtYra9PO9OoMREhldKtQkZ5TVw==} + engines: {node: '>=22.23.4', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1030,13 +1942,20 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - axios@1.18.0: - resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} babylon@6.18.0: resolution: {integrity: sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==} hasBin: true + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1056,6 +1975,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + body-parser@1.20.4: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1063,11 +1985,14 @@ packages: bole@5.0.28: resolution: {integrity: sha512-l+yybyZLV7zTD6EuGxoXsilpER1ctMCpdOqjSYNigJJma39ha85fzCtYccPx06oR1u7uCQLOcUAFFzvfXVBmuQ==} - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1136,6 +2061,9 @@ packages: capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai-as-promised@7.1.2: resolution: {integrity: sha512-aBDHZxRzYnUYuIAIPBH2s511DjlKPzXNlXSGFC8CwmroWQLfrW0LtE1nK3MAwwNhJPa9raEjNCmRoFpG0Hurdw==} peerDependencies: @@ -1153,6 +2081,18 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -1167,6 +2107,14 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + class-transformer@0.5.1: resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} @@ -1197,14 +2145,25 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + cluster-key-slot@1.1.1: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1222,10 +2181,17 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1234,9 +2200,9 @@ packages: resolution: {integrity: sha512-i0/MaqBtdbnJ4XQs4Pmyb+oFQl+q0lsAmokVUH92SlSw4fkeAcG3bVon+Qt7hmtF+u3Het6o4VgrcY3qAoEB6w==} engines: {node: ^12.20.0 || >=14} - commander@9.5.0: - resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} - engines: {node: ^12.20.0 || >=14} + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} @@ -1277,6 +2243,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} @@ -1284,6 +2253,14 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cosmiconfig-typescript-loader@4.4.0: resolution: {integrity: sha512-BabizFdC3wBHhbI4kJh0VkQP9GkBfoHPydD0COMce1nJ1kJAB3F2TmJ/I7diULBKtmEWSwEbuN/KDtgnmUUVmw==} engines: {node: '>=v14.21.3'} @@ -1309,10 +2286,32 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + dargs@7.0.0: resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==} engines: {node: '>=8'} @@ -1365,6 +2364,9 @@ packages: resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} engines: {node: '>=10'} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-eql@4.1.4: resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} engines: {node: '>=6'} @@ -1376,6 +2378,9 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1384,6 +2389,13 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1392,6 +2404,17 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@6.0.3: + resolution: {integrity: sha512-3woMEITftG1wKeO/4d/QXH1DVKctTSZg3jNGPpjp9hNr5Hos8MCFF5McTML5ZtgKDObm2R591B0spRp2IVernA==} + engines: {node: '>=18'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} @@ -1404,10 +2427,27 @@ packages: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -1416,6 +2456,10 @@ packages: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1435,6 +2479,12 @@ packages: electron-to-chromium@1.5.340: resolution: {integrity: sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==} + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1456,9 +2506,20 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} @@ -1470,6 +2531,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@3.0.4: + resolution: {integrity: sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -1481,6 +2545,17 @@ packages: es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1496,6 +2571,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + esm@3.2.25: resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} engines: {node: '>=6'} @@ -1505,6 +2584,27 @@ packages: engines: {node: '>=4'} hasBin: true + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.1: + resolution: {integrity: sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -1513,6 +2613,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -1525,6 +2628,9 @@ packages: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -1552,18 +2658,36 @@ packages: fast-string-truncated-width@1.2.1: resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + fast-string-width@1.1.0: resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@4.2.2: + resolution: {integrity: sha512-EwVpUMzLVNOmcWW0zi5G3JJwkopB6XXjnVvNBGfqrMMsVew2hkQWl5e/pe/CfYUeS6jt4cIO2MnamEW7v/7wPw==} fast-wrap-ansi@0.1.6: resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fetch-blob@2.1.2: resolution: {integrity: sha512-YKqtUDwqLyfyMnmbw8XD6Q8j9i/HggKtPEI+pZ1+8bvheBu78biSmNaXWusx1TauGqtUUGx/cBb1mKdq2rLYow==} engines: {node: ^10.17.0 || >=12.3.0} @@ -1605,6 +2729,13 @@ packages: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fnv1a-64@0.1.2: + resolution: {integrity: sha512-mJbcILTPybAR1jdOwt/lVv8N2cffeJFFP+gVbSAwLEx1ujXH27RpPd8Rokec/KX9c76UYIB6Co+H4lQzxPnJfA==} + follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -1614,6 +2745,13 @@ packages: debug: optional: true + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + foreground-child@2.0.0: resolution: {integrity: sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==} engines: {node: '>=8.0.0'} @@ -1702,6 +2840,10 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-tsconfig@5.0.0-beta.6: + resolution: {integrity: sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==} + engines: {node: '>=20.20.0'} + getopts@2.3.0: resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==} @@ -1711,6 +2853,9 @@ packages: deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1747,6 +2892,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + hard-rejection@2.1.0: resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} engines: {node: '>=6'} @@ -1778,6 +2926,39 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.4: + resolution: {integrity: sha512-Yq7XlXor0Cpb71sI2E0zpv8QxQ5a6rfhDEETk4bcgrFX2SvJAY2PHocWwFtL6U9qyfiyByW3wZ6sZEfP2kW81g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-html@9.0.6: + resolution: {integrity: sha512-1SZUxCN3TtJAE7e2BQN2MBHsoZ1gnmi9GoCP5Di59CSWptIezSAW09yDZyVbanb78V9eCwrKqdQsjtfVTxYHWA==} + + hast-util-to-jsx-runtime@2.3.7: + resolution: {integrity: sha512-F7DNV2NMcTxA4GqPVHnXzX+mpoPHAIY9rJAVH3CZ1FdBZOcBUEjUuvMkMpHVs/sicEtznh9tMGj5WUR9S4E2rg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-text@4.0.3: + resolution: {integrity: sha512-5yrlWOvGFs6CqHriUpSsNrjzT/Tdo+IOQudbznxOn8K5qblp18MJyzOg8IgDOKQ8MYgQcJA7SusuCjSpDr8mSA==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.3: + resolution: {integrity: sha512-1ALZXSmCGmW3h8AMz4R4+QKSKFi7sNgXLVwmwB8FYVtl1CwQ3WDkSAEY/d5tp1g/vbQgq2s1WlHgfO3jbw1TsA==} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -1795,6 +2976,15 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-cache-semantics@4.3.0: + resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1863,6 +3053,12 @@ packages: resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} engines: {node: '>=10'} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + inline-style-parser@0.2.9: + resolution: {integrity: sha512-IttN8BqKLxzUrnTqHI+/hwAJQItBAUjdoHERCDv+oOuX48VP6bzW8+QjOXSfoevzq3YrYAwX1uLHp8TqO9yIeg==} + interpret@2.2.0: resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} engines: {node: '>= 0.10'} @@ -1871,6 +3067,15 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -1882,6 +3087,14 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1894,6 +3107,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-installed-globally@0.4.0: resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} engines: {node: '>=10'} @@ -1930,6 +3146,10 @@ packages: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -2006,12 +3226,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -2037,6 +3257,12 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -2054,9 +3280,13 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - knex@2.4.2: - resolution: {integrity: sha512-tMI1M7a+xwHhPxjbl/H9K1kHX+VncEYcvCx5K00M16bWvpYPKAZd6QrCu68PtHAdIZNQPWZn0GVhqVBEthGWCg==} - engines: {node: '>=12'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + knex@3.2.9: + resolution: {integrity: sha512-dtAILTjBMaG8YloP5oBxohDIKyIsdQ/TkcVvSjhsksvsjeH63Y0PADyuMDfNZKbVT3Rlx3vEYVBlecbPT/KerA==} + engines: {node: '>=16'} hasBin: true peerDependencies: better-sqlite3: '*' @@ -2064,6 +3294,7 @@ packages: mysql2: '*' pg: '*' pg-native: '*' + pg-query-stream: ^4.14.0 sqlite3: '*' tedious: '*' peerDependenciesMeta: @@ -2077,6 +3308,8 @@ packages: optional: true pg-native: optional: true + pg-query-stream: + optional: true sqlite3: optional: true tedious: @@ -2090,6 +3323,80 @@ packages: knuth-shuffle-seeded@1.0.6: resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2165,6 +3472,9 @@ packages: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2178,6 +3488,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2197,6 +3511,12 @@ packages: magic-string@0.16.0: resolution: {integrity: sha512-c4BEos3y6G2qO0B9X7K0FVLOPT9uGrjYwYRLFmDqyl5YMboUviyecnXWp94fJTSMwPw2/sf+CEYt5AGpmklkkQ==} + magic-string@1.4.5: + resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -2224,10 +3544,74 @@ packages: resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} engines: {node: '>=8'} + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + + mdast-util-find-and-replace@3.0.4: + resolution: {integrity: sha512-z4B5c6jrnJ1ltOQemhWYPBSAo6y42Z8Gt2yEgfgNTMcLSU59B8bfj8Z8AnAlJrBGLnqkdAUZ7jdDEqQw3KuipQ==} + + mdast-util-from-markdown@2.1.0: + resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.1: + resolution: {integrity: sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.2.1: + resolution: {integrity: sha512-ThatlCBxG2GE+mljhNSfZPv56d3QrGiRh97jjYgIk80IzS4uU6d7cHAaexCYSAmUCCcOs3grJ07kX3oTGsFUOw==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -2258,6 +3642,114 @@ packages: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.2: + resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2291,6 +3783,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + miniflare@5.20261012.0-beta: + resolution: {integrity: sha512-Cjrs+vHHQ7krLO4HYELYpYBJBuMhcnNZD/Z5z/UcQSnFqWSQY3DhtTCFalXgI1/yO1iK+YrRKK8EYkumrEopxg==} + engines: {node: '>=22.0.0'} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -2337,6 +3833,10 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -2346,9 +3846,17 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + ndjson@2.0.0: resolution: {integrity: sha512-nGl7LRGrzugTtaFcJMhLbpzJM6XdivmbkdlaGcrk/LXg2KL/YBC6z1g70xh0/al+oFuVFP8N8kiWRucmeEH/qQ==} engines: {node: '>=10'} @@ -2358,15 +3866,25 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} + engines: {node: '>= 10'} + nise@5.1.9: resolution: {integrity: sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==} + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} node-addon-api@3.2.1: resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2384,6 +3902,9 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + node-preload@0.2.1: resolution: {integrity: sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==} engines: {node: '>=8'} @@ -2421,6 +3942,9 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nyc@15.1.0: resolution: {integrity: sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==} engines: {node: '>=8.9'} @@ -2441,6 +3965,13 @@ packages: resolution: {integrity: sha512-+8hwcz/JnQ9EpLIXzN0Rs7DLsBpJNT/xYehtB/jU93tHYr5BFEO8E+JGQNOSqE7opVzz5cGksKFHt7uUJVLSjQ==} engines: {node: '>=0.10.0'} + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -2460,6 +3991,12 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + opener@1.5.2: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true @@ -2487,6 +4024,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -2511,6 +4052,14 @@ packages: resolution: {integrity: sha512-km0sP12uE0dOZ5qP+s7kGVf07QngxyG0gS8sYFvFWhqlgzOsSy+m71aUejf/0akxj5W7gE//2G74qTv6b4iMog==} engines: {node: '>=10'} + p-queue@9.3.5: + resolution: {integrity: sha512-Lki/zhXFVmDhesBUn/yHRLW9Bte7mE7xoFHzTR8sIomV7a2z+LxYAPkGvdfidNiu4fKSfjtawixxSHcZci5QcQ==} + engines: {node: '>=20'} + + p-timeout@7.0.2: + resolution: {integrity: sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==} + engines: {node: '>=20'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -2525,6 +4074,9 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + packet-reader@1.0.0: resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} @@ -2536,10 +4088,16 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + parse-ms@3.0.0: resolution: {integrity: sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==} engines: {node: '>=12'} @@ -2548,10 +4106,16 @@ packages: resolution: {integrity: sha512-InpdgIdNe5xWMEUcrVQUniQKwnggBtJ7+SCwh7zQAZwbbIYZV9XdgJyhtmDSSvykFyQXoe4BINnzKTfCwWLs5g==} engines: {node: '>=8.15'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2585,14 +4149,17 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} pg-connection-string@2.12.0: resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} - pg-connection-string@2.5.0: - resolution: {integrity: sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==} + pg-connection-string@2.6.2: + resolution: {integrity: sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==} pg-cursor@2.19.0: resolution: {integrity: sha512-J5cF1MUz7LRJ9emOqF/06QjabMHMZy587rSPF0UuA8rCwKeeYl2co8Pp+6k5UU9YrAYHMzWkLxilfZB0hqsWWw==} @@ -2632,6 +4199,9 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2639,6 +4209,10 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -2664,6 +4238,10 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -2685,10 +4263,23 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} + engines: {node: '>=14'} + hasBin: true + pretty-ms@8.0.0: resolution: {integrity: sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==} engines: {node: '>=14.16'} + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + process-on-spawn@1.1.0: resolution: {integrity: sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==} engines: {node: '>=8'} @@ -2710,8 +4301,11 @@ packages: property-expr@2.0.6: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} proxy-from-env@2.1.0: @@ -2746,6 +4340,9 @@ packages: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + ramda@0.28.0: resolution: {integrity: sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==} @@ -2792,6 +4389,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -2800,6 +4401,20 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -2814,6 +4429,15 @@ packages: resolution: {integrity: sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==} deprecated: This version has a critical bug in fallback handling. Please upgrade to reflect-metadata@0.2.2 or newer. + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp-match-indices@1.0.2: resolution: {integrity: sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==} @@ -2821,10 +4445,38 @@ packages: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + release-zalgo@1.0.0: resolution: {integrity: sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==} engines: {node: '>=4'} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-smartypants@3.0.3: + resolution: {integrity: sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + rename-overwrite@4.0.4: resolution: {integrity: sha512-5MC+p5npnyaJlFkwTHb0pqU2mkUkkW65ZWH8qwxcDlv+5nchtalcdzG+gaaianbWWwvwxi7vu7WSg6jdCweKug==} engines: {node: '>=12.10'} @@ -2833,6 +4485,12 @@ packages: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2856,6 +4514,9 @@ packages: resolution: {integrity: sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-pkg@2.0.0: resolution: {integrity: sha512-+1lzwXehGCXSeryaISr6WujZzowloigEofRB+dj75y9RRa/obVcYgbHJd53tdYw8pvZj8GojXaaENws8Ktw/hQ==} engines: {node: '>=8'} @@ -2869,6 +4530,18 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2886,6 +4559,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rolldown@1.2.16: + resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2902,6 +4580,13 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + satteri@0.10.5: + resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -2931,6 +4616,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -2949,6 +4639,15 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2960,6 +4659,10 @@ packages: shell-quote-word@1.0.1: resolution: {integrity: sha512-lT297f1WLAdq0A4O+AknIFRP6kkiI3s8C913eJ0XqBxJbZPGWUNkRQk2u8zk4bEAjUJ5i+fSLwB6z1HzeT+DEg==} + shiki@4.6.0: + resolution: {integrity: sha512-imjLsVHgPuWhvpU3P+zOekvMYqnel7QmKsFC4Sn0fCuAvaaalBsxwKEsrU8Ko5BdFnZA6Go/8a0RzahS634UgA==} + engines: {node: '>=20'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -3004,12 +4707,20 @@ packages: resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} engines: {node: '>=12'} + smol-toml@1.9.1: + resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} + engines: {node: '>= 18'} + sonic-boom@3.8.1: resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -3017,6 +4728,17 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + source-map@0.8.0: + resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spawn-wrap@2.0.0: resolution: {integrity: sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==} engines: {node: '>=8'} @@ -3057,15 +4779,18 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + stdin-discarder@0.3.2: resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} engines: {node: '>=18'} - stream-chain@3.6.1: - resolution: {integrity: sha512-M4BQpNPI71uumkVXjl4y+mIormQXdo4R0pSR23mcLbn6D+kpvu7Kx2g1hf0jRB76Zb1IT1M06OIGghMTAtZdyQ==} + stream-chain@4.2.6: + resolution: {integrity: sha512-1zeJ8CrtJfmiba26ui8jXkq/xLRFvhzkdH02D5QLO9Cnovgeb28IJxg88DdraWnjnkbOol/++uIAybJbJhk7ig==} - stream-json@2.1.0: - resolution: {integrity: sha512-9gV/ywtebMn3DdKnNKYCb9iESvgR1dHbucNV+bRGvdvy+jV4c9FFgYKmENhpKv58jSwvs90Wk80RhfKk1KxHPg==} + stream-json@3.7.0: + resolution: {integrity: sha512-rCSBdcBP/bPk6T8QFcxAj1MSzAuc5i49cYW6IE7sYObNEPccBJIUiL6fU9c9BWt40aJK0siVynLX7lWaW8alXw==} string-argv@0.3.1: resolution: {integrity: sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==} @@ -3079,16 +4804,27 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string-width@8.2.0: resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + string.fromcodepoint@0.2.1: resolution: {integrity: sha512-n69H31OnxSGSZyZbgBlvYIXlrMhJQ0dQAX1js1QDhpaUH6zmU3QYlj07bCwCNlPOu3oRXIubGPl2gDGnHsiCqg==} string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3125,9 +4861,22 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + style-to-object@2.0.2: + resolution: {integrity: sha512-QmbXPUylqU80HAgTOJeEOdbA40W9KtxmjMhQy+tq+b8F2aoh3aQLuy82hh2jm2qsC9hR9eI0+eQidTMydKcqRA==} + summary@2.1.0: resolution: {integrity: sha512-nMIjMrd5Z2nuB2RZCKJfFMjgS3fygbeyGk9PxPPaJR1RIcyN9yn4A63Isovzm3ZtQuEkLBVgMdPup8UeLH7aQw==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3140,6 +4889,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + tarn@3.0.2: resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} engines: {node: '>=8.0.0'} @@ -3185,6 +4939,24 @@ packages: tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinyclip@1.0.3: + resolution: {integrity: sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyps@0.5.1: + resolution: {integrity: sha512-B6Ykif5HflC3AdDkS4Zst/2muYVfeXoVMy+KorqH80s/1x4vunIORpNySH681X7fjj+dFM9MDKD6/Hsccjb87Q==} + tmp@0.2.5: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} @@ -3216,10 +4988,16 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + trim-newlines@3.0.1: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -3300,21 +5078,88 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + typescript@5.7.3: resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} engines: {node: '>=14.17'} hasBin: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + + undici@8.12.2: + resolution: {integrity: sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==} + engines: {node: '>=22.19.0'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unescape-js@1.1.4: resolution: {integrity: sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g==} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@1.0.3: + resolution: {integrity: sha512-XGnEwj5sWdCbiBX01mWLSBncS0yMRjnLygau3IfdOlfjMacNIQvTGW4VH7pWBq+gGWArfSef/CensMLjMmIr/A==} + unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit-parents@6.1.0: + resolution: {integrity: sha512-vKXxz6O38sKDj1mtx/k2cWvkkFBuYGx6uGrlzBkVeWCRF/F8sdI0j3Wbkz/5rVNRRuVc8UrgRdbCxNhmYXgP3w==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -3327,6 +5172,68 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unstorage@1.17.6: + resolution: {integrity: sha512-gjKtcmn/KwZuJZO9o2sc0esAQnhmTw0LviDHwevTXbJMYs0dRTp8ZEEWXHU0vTpzbN3cA0yxlTeyXssMp3zDqw==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3379,16 +5286,185 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verkit@0.5.0: + resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==} + engines: {node: '>=18.12.0'} + version-selector-type@3.0.0: resolution: {integrity: sha512-PSvMIZS7C1MuVNBXl/CDG2pZq8EXy/NW2dHIdm3bVP5N0PC8utDK8ttXLXj44Gn3J0lQE3U7Mpm1estAOd+eiA==} engines: {node: '>=10.13'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@8.3.5: + resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vlq@0.2.3: resolution: {integrity: sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==} + volar-service-css@0.0.71: + resolution: {integrity: sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.71: + resolution: {integrity: sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.71: + resolution: {integrity: sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.71: + resolution: {integrity: sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.71: + resolution: {integrity: sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.71: + resolution: {integrity: sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.71: + resolution: {integrity: sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-jsonrpc@9.0.3: + resolution: {integrity: sha512-qETYf9PCl1lVfwR+oBmMfc4vP2nqLNA35ABxorw3j06+xlKRgDVxbLgKm4Y/xPL0YWdeDjLoI4ImICIT3nB0zQ==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-protocol@3.18.4: + resolution: {integrity: sha512-CCXbyr99dafRJzJH/0Ryk7ly5T8XPmSR0//OuF2w4m+9JiOin7zcAT5iET8GRE7Z9taFBejb3GbM0a0mbjdbrg==} + + vscode-languageserver-textdocument@1.0.15: + resolution: {integrity: sha512-Gsfr9S8LU3S6885cS+NaleasJ0n3V5y/SOTqHCifQiXWFw3G9w9/YItDfwHrlCz+dSZWWZ8ilYVMcJe7Sz7Sxw==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver-types@3.18.4: + resolution: {integrity: sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -3403,9 +5479,24 @@ packages: engines: {node: '>= 8'} hasBin: true + workerd@1.20261012.1: + resolution: {integrity: sha512-tEHoMPsMga0seVjxfsR8Pd3f8IRqYwkq++dTUKI5Gdgj6zuSDluYwisydq+dn90SsIACwBquJUQ1nBpcOqaWTA==} + engines: {node: '>=16'} + hasBin: true + workerpool@9.3.4: resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} + wrangler@4.151.0: + resolution: {integrity: sha512-ra8MpzSR+ClkbGofu9S+AmAKjytS60MqSuayXKpa1CAYIzP+NIc8q6EiF55Gz5PS3lxT4qcvMuPHihCNhTPpDg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20261006.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -3418,6 +5509,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3444,6 +5539,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -3457,11 +5555,20 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml-language-server@1.23.0: + resolution: {integrity: sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==} + hasBin: true + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -3474,6 +5581,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs-unparser@2.0.0: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} @@ -3486,6 +5597,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@18.2.0: + resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -3494,10 +5609,20 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + yup@1.2.0: resolution: {integrity: sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ==} @@ -3507,13 +5632,174 @@ packages: peerDependencies: zod: ^3.18.0 - zod@3.22.4: - resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/astro2tsx@0.1.3(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/runtime' + + '@astrojs/check@0.9.10(@emnapi/runtime@1.11.3)(prettier@3.9.10)(typescript@5.9.3)': + dependencies: + '@astrojs/language-server': 2.17.2(@emnapi/runtime@1.11.3)(prettier@3.9.10)(typescript@5.9.3) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 5.9.3 + yargs: 18.2.0 + transitivePeerDependencies: + - '@emnapi/runtime' + - prettier + - prettier-plugin-astro + + '@astrojs/compiler-binding-android-arm64@0.5.2': + optional: true + + '@astrojs/compiler-binding-darwin-arm64@0.5.2': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.5.2': + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.5.2': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.2': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.2': + optional: true + + '@astrojs/compiler-binding@0.5.2': + optionalDependencies: + '@astrojs/compiler-binding-android-arm64': 0.5.2 + '@astrojs/compiler-binding-darwin-arm64': 0.5.2 + '@astrojs/compiler-binding-darwin-x64': 0.5.2 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.5.2 + '@astrojs/compiler-binding-linux-arm64-musl': 0.5.2 + '@astrojs/compiler-binding-linux-x64-gnu': 0.5.2 + '@astrojs/compiler-binding-linux-x64-musl': 0.5.2 + '@astrojs/compiler-binding-wasm32-wasi': 0.5.2 + '@astrojs/compiler-binding-win32-arm64-msvc': 0.5.2 + '@astrojs/compiler-binding-win32-x64-msvc': 0.5.2 + + '@astrojs/compiler-rs@0.5.2': + dependencies: + '@astrojs/compiler-binding': 0.5.2 + + '@astrojs/internal-helpers@0.13.0': + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + picomatch: 4.0.7 + retext-smartypants: 6.2.0 + shiki: 4.6.0 + smol-toml: 1.9.1 + unified: 11.0.5 + yaml: 2.9.1 + + '@astrojs/language-server@2.17.2(@emnapi/runtime@1.11.3)(prettier@3.9.10)(typescript@5.9.3)': + dependencies: + '@astrojs/astro2tsx': 0.1.3(@emnapi/runtime@1.11.3) + '@astrojs/yaml2ts': 0.2.4 + '@volar/kit': 2.4.28(typescript@5.9.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28 + '@volar/language-service': 2.4.28 + muggle-string: 0.4.1 + tinyglobby: 0.2.17 + volar-service-css: 0.0.71(@volar/language-service@2.4.28) + volar-service-emmet: 0.0.71(@volar/language-service@2.4.28) + volar-service-html: 0.0.71(@volar/language-service@2.4.28) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.28)(prettier@3.9.10) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.28) + volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.28) + volar-service-yaml: 0.0.71(@volar/language-service@2.4.28) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.2.0 + optionalDependencies: + prettier: 3.9.10 + transitivePeerDependencies: + - '@emnapi/runtime' + - typescript + + '@astrojs/markdown-remark@7.3.5': + dependencies: + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/prism': 4.1.2 + '@mdx-js/mdx': 3.1.1 + acorn: 8.19.0 + estree-util-visit: 2.0.0 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-html: 9.0.6 + hast-util-to-text: 4.0.3 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-smartypants: 3.0.3 + source-map: 0.8.0 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/markdown-satteri@0.4.6': + dependencies: + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/prism': 4.1.2 + github-slugger: 2.0.0 + satteri: 0.10.5 - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + '@astrojs/prism@4.1.2': + dependencies: + prismjs: 1.30.0 -snapshots: + '@astrojs/telemetry@3.4.2': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + package-manager-detector: 1.9.0 + + '@astrojs/yaml2ts@0.2.4': + dependencies: + yaml: 2.8.3 '@babel/code-frame@7.29.0': dependencies: @@ -3579,8 +5865,12 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.29.2': @@ -3592,6 +5882,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -3617,6 +5911,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@biomejs/biome@2.4.12': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.4.12 @@ -3652,6 +5951,41 @@ snapshots: '@biomejs/cli-win32-x64@2.4.12': optional: true + '@bruits/satteri-darwin-arm64@0.10.5': + optional: true + + '@bruits/satteri-darwin-x64@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-musl@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-musl@0.10.5': + optional: true + + '@bruits/satteri-wasm32-wasi@0.10.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.10.5': + optional: true + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -3775,7 +6109,7 @@ snapshots: '@changesets/parse@0.4.3': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.2.0 + js-yaml: 4.3.2 '@changesets/pre@2.0.2': dependencies: @@ -3815,6 +6149,11 @@ snapshots: fast-wrap-ansi: 0.1.6 sisteransi: 1.0.5 + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@clack/prompts@1.2.0': dependencies: '@clack/core': 1.2.0 @@ -3822,6 +6161,36 @@ snapshots: fast-wrap-ansi: 0.1.6 sisteransi: 1.0.5 + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261012.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20261012.1 + + '@cloudflare/workerd-darwin-64@1.20261012.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20261012.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20261012.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20261012.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20261012.1': + optional: true + '@colors/colors@1.5.0': optional: true @@ -3995,91 +6364,331 @@ snapshots: yaml: 2.8.3 yup: 1.2.0 - '@cucumber/gherkin-streams@5.0.1(@cucumber/gherkin@27.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@24.0.1))(@cucumber/messages@24.0.1)': - dependencies: - '@cucumber/gherkin': 27.0.0 - '@cucumber/message-streams': 4.0.1(@cucumber/messages@24.0.1) - '@cucumber/messages': 24.0.1 - commander: 9.1.0 - source-map-support: 0.5.21 + '@cucumber/gherkin-streams@5.0.1(@cucumber/gherkin@27.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@24.0.1))(@cucumber/messages@24.0.1)': + dependencies: + '@cucumber/gherkin': 27.0.0 + '@cucumber/message-streams': 4.0.1(@cucumber/messages@24.0.1) + '@cucumber/messages': 24.0.1 + commander: 9.1.0 + source-map-support: 0.5.21 + + '@cucumber/gherkin-utils@8.0.5': + dependencies: + '@cucumber/gherkin': 26.2.0 + '@cucumber/messages': 22.0.0 + '@teppeis/multimaps': 3.0.0 + commander: 10.0.1 + source-map-support: 0.5.21 + + '@cucumber/gherkin@26.2.0': + dependencies: + '@cucumber/messages': 22.0.0 + + '@cucumber/gherkin@27.0.0': + dependencies: + '@cucumber/messages': 22.0.0 + + '@cucumber/html-formatter@21.2.0(@cucumber/messages@24.0.1)': + dependencies: + '@cucumber/messages': 24.0.1 + + '@cucumber/message-streams@4.0.1(@cucumber/messages@24.0.1)': + dependencies: + '@cucumber/messages': 24.0.1 + + '@cucumber/messages@22.0.0': + dependencies: + '@types/uuid': 9.0.1 + class-transformer: 0.5.1 + reflect-metadata: 0.1.13 + uuid: 9.0.0 + + '@cucumber/messages@24.0.1': + dependencies: + '@types/uuid': 9.0.7 + class-transformer: 0.5.1 + reflect-metadata: 0.2.1 + uuid: 9.0.1 + + '@cucumber/pretty-formatter@1.0.0(@cucumber/cucumber@10.2.1)(@cucumber/messages@24.0.1)': + dependencies: + '@cucumber/cucumber': 10.2.1 + '@cucumber/messages': 24.0.1 + ansi-styles: 5.2.0 + cli-table3: 0.6.5 + figures: 3.2.0 + ts-dedent: 2.2.0 + + '@cucumber/tag-expressions@6.0.0': {} + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + + '@ericcornelissen/bash-parser@0.5.3': + dependencies: + array-last: 1.3.0 + babylon: 6.18.0 + compose-function: 3.0.3 + filter-obj: 1.1.0 + has-own-property: 0.1.0 + identity-function: 1.0.0 + is-iterable: 1.1.1 + iterable-lookahead: 1.0.0 + lodash.curry: 4.1.1 + magic-string: 0.16.0 + map-obj: 2.0.0 + object-pairs: 0.1.0 + object-values: 1.0.0 + reverse-arguments: 1.0.0 + shell-quote-word: 1.0.1 + to-pascal-case: 1.0.0 + unescape-js: 1.1.4 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@fontsource-variable/inter@5.3.0': {} + + '@fontsource/jetbrains-mono@5.3.0': {} + + '@getalby/lightning-tools@5.2.1': {} + + '@getalby/sdk@5.1.2(typescript@5.7.3)': + dependencies: + '@getalby/lightning-tools': 5.2.1 + nostr-tools: 2.15.0(typescript@5.7.3) + transitivePeerDependencies: + - typescript + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true - '@cucumber/gherkin-utils@8.0.5': - dependencies: - '@cucumber/gherkin': 26.2.0 - '@cucumber/messages': 22.0.0 - '@teppeis/multimaps': 3.0.0 - commander: 10.0.1 - source-map-support: 0.5.21 + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true - '@cucumber/gherkin@26.2.0': - dependencies: - '@cucumber/messages': 22.0.0 + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true - '@cucumber/gherkin@27.0.0': - dependencies: - '@cucumber/messages': 22.0.0 + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true - '@cucumber/html-formatter@21.2.0(@cucumber/messages@24.0.1)': - dependencies: - '@cucumber/messages': 24.0.1 + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true - '@cucumber/message-streams@4.0.1(@cucumber/messages@24.0.1)': - dependencies: - '@cucumber/messages': 24.0.1 + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true - '@cucumber/messages@22.0.0': - dependencies: - '@types/uuid': 9.0.1 - class-transformer: 0.5.1 - reflect-metadata: 0.1.13 - uuid: 9.0.0 + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true - '@cucumber/messages@24.0.1': - dependencies: - '@types/uuid': 9.0.7 - class-transformer: 0.5.1 - reflect-metadata: 0.2.1 - uuid: 9.0.1 + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true - '@cucumber/pretty-formatter@1.0.0(@cucumber/cucumber@10.2.1)(@cucumber/messages@24.0.1)': - dependencies: - '@cucumber/cucumber': 10.2.1 - '@cucumber/messages': 24.0.1 - ansi-styles: 5.2.0 - cli-table3: 0.6.5 - figures: 3.2.0 - ts-dedent: 2.2.0 + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true - '@cucumber/tag-expressions@6.0.0': {} + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true - '@ericcornelissen/bash-parser@0.5.3': - dependencies: - array-last: 1.3.0 - babylon: 6.18.0 - compose-function: 3.0.3 - filter-obj: 1.1.0 - has-own-property: 0.1.0 - identity-function: 1.0.0 - is-iterable: 1.1.1 - iterable-lookahead: 1.0.0 - lodash.curry: 4.1.1 - magic-string: 0.16.0 - map-obj: 2.0.0 - object-pairs: 0.1.0 - object-values: 1.0.0 - reverse-arguments: 1.0.0 - shell-quote-word: 1.0.1 - to-pascal-case: 1.0.0 - unescape-js: 1.1.4 + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true - '@getalby/lightning-tools@5.2.1': {} + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true - '@getalby/sdk@5.1.2(typescript@5.7.3)': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@getalby/lightning-tools': 5.2.1 - nostr-tools: 2.15.0(typescript@5.7.3) - transitivePeerDependencies: - - typescript + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true '@inquirer/external-editor@1.0.3(@types/node@24.12.2)': dependencies: @@ -4102,7 +6711,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.2 + js-yaml: 3.15.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -4121,6 +6730,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -4147,6 +6758,56 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdx': 2.0.14 + acorn: 8.19.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.1 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.7 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.19.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + + '@napi-rs/wasm-runtime@1.2.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + optional: true + '@noble/ciphers@0.5.3': {} '@noble/curves@1.1.0': @@ -4249,6 +6910,10 @@ snapshots: '@opentelemetry/semantic-conventions@1.41.1': {} + '@oslojs/encoding@1.1.0': {} + + '@oxc-project/types@0.156.0': {} + '@pkgjs/parseargs@0.11.0': {} '@pnpm/constants@7.1.1': {} @@ -4281,7 +6946,7 @@ snapshots: '@pnpm/npm-package-arg@1.0.0': dependencies: hosted-git-info: 4.1.0 - semver: 7.7.4 + semver: 7.8.5 validate-npm-package-name: 4.0.0 '@pnpm/npm-resolver@18.0.0(@pnpm/logger@5.0.0)': @@ -4305,7 +6970,7 @@ snapshots: path-temp: 2.1.1 ramda: '@pnpm/ramda@0.28.1' rename-overwrite: 4.0.4 - semver: 7.7.4 + semver: 7.8.5 ssri: 10.0.5 version-selector-type: 3.0.0 transitivePeerDependencies: @@ -4315,7 +6980,7 @@ snapshots: '@pnpm/resolve-workspace-range@5.0.1': dependencies: - semver: 7.7.4 + semver: 7.8.5 '@pnpm/resolver-base@11.0.0': dependencies: @@ -4333,6 +6998,18 @@ snapshots: - '@pnpm/logger' - domexception + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@redis/bloom@1.1.0(@redis/client@1.4.2)': dependencies: '@redis/client': 1.4.2 @@ -4359,6 +7036,53 @@ snapshots: dependencies: '@redis/client': 1.4.2 + '@rolldown/binding-android-arm-eabi@1.2.16': + optional: true + + '@rolldown/binding-android-arm64@1.2.16': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.16': + optional: true + + '@rolldown/binding-darwin-x64@1.2.16': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.16': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.16': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.16': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.16': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.16': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.16': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@scure/base@1.1.1': {} '@scure/bip32@1.3.1': @@ -4372,6 +7096,48 @@ snapshots: '@noble/hashes': 1.3.1 '@scure/base': 1.1.1 + '@shikijs/core@4.6.0': + dependencies: + '@shikijs/primitive': 4.6.0 + '@shikijs/types': 4.6.0 + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.6 + + '@shikijs/engine-javascript@4.6.0': + dependencies: + '@shikijs/types': 4.6.0 + '@shikijs/vscode-textmate': 10.1.0 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.6.0': + dependencies: + '@shikijs/types': 4.6.0 + '@shikijs/vscode-textmate': 10.1.0 + + '@shikijs/langs@4.6.0': + dependencies: + '@shikijs/types': 4.6.0 + + '@shikijs/primitive@4.6.0': + dependencies: + '@shikijs/types': 4.6.0 + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.6.0': + dependencies: + '@shikijs/types': 4.6.0 + + '@shikijs/types@4.6.0': + dependencies: + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.1.0': {} + + '@sindresorhus/is@7.2.0': {} + '@sinonjs/commons@2.0.0': dependencies: type-detect: 4.0.8 @@ -4402,6 +7168,8 @@ snapshots: ignore: 5.3.2 p-map: 4.0.0 + '@speed-highlight/core@1.2.24': {} + '@teppeis/multimaps@3.0.0': {} '@tsconfig/node10@1.0.12': {} @@ -4412,6 +7180,10 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -4427,6 +7199,16 @@ snapshots: dependencies: '@types/node': 24.12.2 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + '@types/express-serve-static-core@4.19.8': dependencies: '@types/node': 24.12.2 @@ -4441,16 +7223,32 @@ snapshots: '@types/qs': 6.15.0 '@types/serve-static': 2.2.0 + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/http-errors@2.0.5': {} '@types/js-yaml@4.0.5': {} '@types/k6@1.7.0': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.14': {} + '@types/minimist@1.2.5': {} '@types/mocha@9.1.1': {} + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + '@types/node@12.20.55': {} '@types/node@20.5.1': {} @@ -4499,6 +7297,10 @@ snapshots: '@types/strip-json-comments@0.0.30': {} + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@types/uuid@9.0.1': {} '@types/uuid@9.0.7': {} @@ -4507,6 +7309,58 @@ snapshots: dependencies: '@types/node': 24.12.2 + '@ungap/structured-clone@1.4.0': {} + + '@volar/kit@2.4.28(typescript@5.9.3)': + dependencies: + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + typesafe-path: 0.2.2 + typescript: 5.9.3 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.18.4 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + + '@volar/language-service@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.18.4 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-uri: 3.2.0 + + '@vscode/l10n@0.0.18': {} + '@zkochan/retry@0.2.0': {} '@zkochan/rimraf@2.1.3': @@ -4527,12 +7381,18 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + acorn-jsx@5.3.2(acorn@8.19.0): + dependencies: + acorn: 8.19.0 + acorn-walk@8.3.5: dependencies: acorn: 8.16.0 acorn@8.16.0: {} + acorn@8.19.0: {} + agent-base@6.0.2: dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -4544,13 +7404,25 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 + ajv-draft-04@1.0.0(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv-i18n@4.2.0(ajv@8.18.0): + dependencies: + ajv: 8.18.0 + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 4.2.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + am-i-vibing@0.5.0: + dependencies: + process-ancestry: 0.1.0 + ansi-colors@4.1.3: {} ansi-regex@4.1.1: {} @@ -4588,12 +7460,16 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.2: {} + arity-n@1.0.4: {} array-flatten@1.1.1: {} array-ify@1.0.0: {} + array-iterate@2.0.1: {} + array-last@1.3.0: dependencies: is-number: 4.0.0 @@ -4610,11 +7486,103 @@ snapshots: assertion-error@1.1.0: {} + astring@1.9.0: {} + + astro@7.4.4(@astrojs/markdown-remark@7.3.5)(@types/node@24.12.2)(esbuild@0.28.2)(jiti@1.21.0)(yaml@2.9.1): + dependencies: + '@astrojs/compiler-rs': 0.5.2 + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/markdown-satteri': 0.4.6 + '@astrojs/telemetry': 3.4.2 + '@capsizecss/unpack': 4.0.1 + '@clack/prompts': 1.8.1 + '@oslojs/encoding': 1.1.0 + am-i-vibing: 0.5.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 2.0.1 + devalue: 6.0.3 + diff: 9.0.0 + dset: 3.1.4 + es-module-lexer: 3.0.4 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.6 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.3.0 + jsonc-parser: 3.3.1 + magic-string: 1.4.5 + magicast: 0.5.5 + mrmime: 2.0.1 + neotraverse: 1.0.1 + obug: 3.0.0 + p-limit: 7.3.3 + p-queue: 9.3.5 + package-manager-detector: 1.9.0 + piccolore: 0.1.3 + picomatch: 4.0.7 + rolldown: 1.2.16 + shiki: 4.6.0 + smol-toml: 1.9.1 + svgo: 4.1.0 + tinyclip: 1.0.3 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyps: 0.5.1 + ultrahtml: 1.7.0 + unifont: 1.0.3 + unstorage: 1.17.6 + verkit: 0.5.0 + vite: 8.3.5(@types/node@24.12.2)(esbuild@0.28.2)(jiti@1.21.0)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.3.5(@types/node@24.12.2)(esbuild@0.28.2)(jiti@1.21.0)(yaml@2.9.1)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.6.7 + optionalDependencies: + '@astrojs/markdown-remark': 7.3.5 + sharp: 0.35.5(@types/node@24.12.2) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - aws4fetch + - db0 + - esbuild + - idb-keyval + - ioredis + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - uploadthing + - yaml + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} - axios@1.18.0: + axios@1.20.0: dependencies: follow-redirects: 1.16.0 form-data: 4.0.6 @@ -4624,8 +7592,12 @@ snapshots: - debug - supports-color + axobject-query@4.1.0: {} + babylon@6.18.0: {} + bail@2.0.2: {} + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -4638,6 +7610,8 @@ snapshots: binary-extensions@2.3.0: {} + blake3-wasm@2.1.5: {} + body-parser@1.20.4: dependencies: bytes: 3.1.2 @@ -4660,12 +7634,14 @@ snapshots: fast-safe-stringify: 2.1.1 individual: 3.0.0 - brace-expansion@1.1.14: + boolbase@1.0.0: {} + + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.0: + brace-expansion@2.1.7: dependencies: balanced-match: 1.0.2 @@ -4694,7 +7670,7 @@ snapshots: builtins@5.1.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 bytes@3.1.2: {} @@ -4737,6 +7713,8 @@ snapshots: tslib: 2.8.1 upper-case-first: 2.0.2 + ccount@2.0.1: {} + chai-as-promised@7.1.2(chai@4.5.0): dependencies: chai: 4.5.0 @@ -4759,6 +7737,14 @@ snapshots: chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chardet@2.1.1: {} check-error@1.0.3: @@ -4781,6 +7767,12 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + ci-info@4.4.0: {} + class-transformer@0.5.1: {} clean-stack@2.2.0: {} @@ -4815,11 +7807,21 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + clone@1.0.4: optional: true + clsx@2.1.1: {} + cluster-key-slot@1.1.1: {} + collapse-white-space@2.1.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4834,13 +7836,17 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + commander@11.1.0: {} + commander@4.1.1: {} commander@9.1.0: {} - commander@9.5.0: {} + common-ancestor-path@2.0.0: {} commondir@1.0.1: {} @@ -4882,10 +7888,16 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@1.2.3: {} + cookie-signature@1.0.7: {} cookie@0.7.2: {} + cookie@1.1.1: {} + + cookie@2.0.1: {} + cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.7.3))(ts-node@10.9.2(@types/node@24.12.2)(typescript@5.7.3))(typescript@5.7.3): dependencies: '@types/node': 20.5.1 @@ -4896,7 +7908,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.7.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.2 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -4904,13 +7916,41 @@ snapshots: create-require@1.1.1: {} - cross-spawn@7.0.6: + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + crypto-random-string@2.0.0: {} + + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.2 + + css-tree@3.2.1: dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 + mdn-data: 2.27.1 + source-map-js: 1.2.2 - crypto-random-string@2.0.0: {} + css-what@7.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 dargs@7.0.0: {} @@ -4943,6 +7983,10 @@ snapshots: decamelize@4.0.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-eql@4.1.4: dependencies: type-detect: 4.1.0 @@ -4956,30 +8000,66 @@ snapshots: clone: 1.0.4 optional: true + defu@6.1.7: {} + delayed-stream@1.0.0: {} depd@2.0.0: {} + dequal@2.0.3: {} + + destr@2.0.5: {} + destroy@1.2.0: {} detect-indent@6.1.0: {} + detect-libc@2.1.2: {} + + devalue@6.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + diff@4.0.4: {} diff@5.2.2: {} diff@7.0.0: {} + diff@9.0.0: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 dotenv@8.6.0: {} + dset@3.1.4: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5002,6 +8082,13 @@ snapshots: electron-to-chromium@1.5.340: {} + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -5021,10 +8108,16 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@4.5.0: {} + + entities@6.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 + error-stack-parser-es@1.0.5: {} + error-stack-parser@2.1.4: dependencies: stackframe: 1.3.4 @@ -5033,6 +8126,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@3.0.4: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -5046,6 +8141,49 @@ snapshots: es6-error@4.1.1: {} + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.19.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -5054,14 +8192,51 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + esm@3.2.25: {} esprima@4.0.1: {} + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + etag@1.8.1: {} event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events@3.3.0: {} execa@5.1.1: @@ -5098,7 +8273,7 @@ snapshots: on-finished: 2.4.1 parseurl: 1.3.3 path-to-regexp: 0.1.13 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.14.2 range-parser: 1.2.1 safe-buffer: 5.2.1 @@ -5112,6 +8287,8 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + extendable-error@0.1.7: {} fast-copy@4.0.3: {} @@ -5140,20 +8317,34 @@ snapshots: fast-string-truncated-width@1.2.1: {} + fast-string-truncated-width@3.0.3: {} + fast-string-width@1.1.0: dependencies: fast-string-truncated-width: 1.2.1 - fast-uri@3.1.2: {} + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@4.2.2: {} fast-wrap-ansi@0.1.6: dependencies: fast-string-width: 1.1.0 + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fetch-blob@2.1.2: {} figures@3.2.0: @@ -5196,8 +8387,20 @@ snapshots: flat@5.0.2: {} + flattie@1.1.1: {} + + fnv1a-64@0.1.2: {} + follow-redirects@1.16.0: {} + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + foreground-child@2.0.0: dependencies: cross-spawn: 7.0.6 @@ -5287,6 +8490,10 @@ snapshots: get-stream@6.0.1: {} + get-tsconfig@5.0.0-beta.6: + dependencies: + resolve-pkg-maps: 1.0.0 + getopts@2.3.0: {} git-raw-commits@2.0.11: @@ -5297,6 +8504,8 @@ snapshots: split2: 3.2.2 through2: 4.0.2 + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5348,6 +8557,18 @@ snapshots: graceful-fs@4.2.11: {} + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + hard-rejection@2.1.0: {} has-ansi@4.0.1: @@ -5373,6 +8594,127 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.4 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.4: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.3 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.4.0 + hast-util-from-parse5: 8.0.4 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-html@9.0.6: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.7: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-object: 2.0.2 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-text@4.0.3: + dependencies: + '@types/hast': 3.0.5 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.3: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + he@1.2.0: {} help-me@5.0.0: {} @@ -5385,6 +8727,12 @@ snapshots: html-escaper@2.0.2: {} + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + http-cache-semantics@4.3.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -5442,10 +8790,23 @@ snapshots: ini@2.0.0: {} + inline-style-parser@0.2.7: {} + + inline-style-parser@0.2.9: {} + interpret@2.2.0: {} ipaddr.js@1.9.1: {} + iron-webcrypto@1.2.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-arrayish@0.2.1: {} is-binary-path@2.1.0: @@ -5456,6 +8817,10 @@ snapshots: dependencies: hasown: 2.0.4 + is-decimal@2.0.1: {} + + is-docker@4.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -5464,6 +8829,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-installed-globally@0.4.0: dependencies: global-dirs: 3.0.1 @@ -5485,6 +8852,8 @@ snapshots: is-plain-obj@2.1.0: {} + is-plain-obj@4.1.0: {} + is-stream@2.0.1: {} is-subdir@1.2.0: @@ -5562,12 +8931,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.2: + js-yaml@3.15.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.2.0: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -5583,6 +8952,10 @@ snapshots: json5@2.2.3: {} + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -5599,10 +8972,12 @@ snapshots: kind-of@6.0.3: {} - knex@2.4.2(pg@8.9.0): + kleur@4.1.5: {} + + knex@3.2.9(pg-query-stream@4.3.0(pg@8.9.0))(pg@8.9.0): dependencies: colorette: 2.0.19 - commander: 9.5.0 + commander: 10.0.1 debug: 4.3.4 escalade: 3.2.0 esm: 3.2.25 @@ -5610,13 +8985,14 @@ snapshots: getopts: 2.3.0 interpret: 2.2.0 lodash: 4.18.1 - pg-connection-string: 2.5.0 + pg-connection-string: 2.6.2 rechoir: 0.8.0 resolve-from: 5.0.0 tarn: 3.0.2 tildify: 2.0.0 optionalDependencies: pg: 8.9.0 + pg-query-stream: 4.3.0(pg@8.9.0) transitivePeerDependencies: - supports-color @@ -5633,7 +9009,7 @@ snapshots: fast-glob: 3.3.2 globby: 13.2.2 jiti: 1.21.0 - js-yaml: 4.2.0 + js-yaml: 4.3.2 micromatch: 4.0.8 minimist: 1.2.8 pretty-ms: 8.0.0 @@ -5649,6 +9025,55 @@ snapshots: dependencies: seed-random: 2.2.0 + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lines-and-columns@1.2.4: {} load-json-file@6.2.0: @@ -5710,6 +9135,8 @@ snapshots: is-unicode-supported: 2.1.0 yoctocolors: 2.1.2 + longest-streak@3.1.0: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -5724,6 +9151,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -5745,6 +9174,16 @@ snapshots: dependencies: vlq: 0.2.3 + magic-string@1.4.5: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 + make-dir@3.1.0: dependencies: semver: 6.3.1 @@ -5765,8 +9204,187 @@ snapshots: map-obj@4.3.0: {} + markdown-extensions@2.0.0: {} + + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + mdast-util-find-and-replace@3.0.4: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.1.0 + + mdast-util-from-markdown@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.3 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.4 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.1 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.1.0 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.1 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.2.1 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.1 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.1 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.1.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.2.1 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.1 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.2.1: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-character: 2.1.1 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + media-typer@0.3.0: {} mem@6.1.1: @@ -5801,6 +9419,276 @@ snapshots: methods@1.1.2: {} + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-table@2.1.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.2 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@8.1.1) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -5822,13 +9710,26 @@ snapshots: min-indent@1.0.1: {} + miniflare@5.20261012.0-beta(@types/node@24.12.2): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.5(@types/node@24.12.2) + undici: 7.29.1 + workerd: 1.20261012.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + minimatch@3.1.5: dependencies: - brace-expansion: 1.1.14 + brace-expansion: 1.1.21 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 2.1.7 minimist-options@4.1.0: dependencies: @@ -5855,7 +9756,7 @@ snapshots: glob: 10.5.0 he: 1.2.0 is-path-inside: 3.0.3 - js-yaml: 4.2.0 + js-yaml: 4.3.2 log-symbols: 4.1.0 minimatch: 9.0.9 ms: 2.1.3 @@ -5898,18 +9799,24 @@ snapshots: mri@1.2.0: {} + mrmime@2.0.1: {} + ms@2.0.0: {} ms@2.1.2: {} ms@2.1.3: {} + muggle-string@0.4.1: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.20: {} + ndjson@2.0.0: dependencies: json-stringify-safe: 5.0.1 @@ -5920,6 +9827,8 @@ snapshots: negotiator@0.6.3: {} + neotraverse@1.0.1: {} + nise@5.1.9: dependencies: '@sinonjs/commons': 3.0.1 @@ -5928,6 +9837,10 @@ snapshots: just-extend: 6.2.0 path-to-regexp: 6.3.0 + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -5936,6 +9849,8 @@ snapshots: node-addon-api@3.2.1: optional: true + node-fetch-native@1.6.7: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -5950,6 +9865,8 @@ snapshots: node-gyp-build@4.8.4: optional: true + node-mock-http@1.0.5: {} + node-preload@0.2.1: dependencies: process-on-spawn: 1.1.0 @@ -5967,7 +9884,7 @@ snapshots: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.16.1 - semver: 7.7.4 + semver: 7.8.5 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -5992,6 +9909,10 @@ snapshots: dependencies: path-key: 3.1.1 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nyc@15.1.0: dependencies: '@istanbuljs/load-nyc-config': 1.1.0 @@ -6030,7 +9951,15 @@ snapshots: object-pairs@0.1.0: {} - object-values@1.0.0: {} + object-values@1.0.0: {} + + obug@3.0.0: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 on-exit-leak-free@2.1.2: {} @@ -6050,6 +9979,14 @@ snapshots: dependencies: mimic-function: 5.0.1 + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + opener@1.5.2: {} ora@9.4.0: @@ -6079,6 +10016,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -6102,6 +10043,13 @@ snapshots: mem: 6.1.1 mimic-fn: 3.1.0 + p-queue@9.3.5: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.2 + + p-timeout@7.0.2: {} + p-try@2.2.0: {} package-hash@4.0.0: @@ -6117,6 +10065,8 @@ snapshots: dependencies: quansync: 0.2.11 + package-manager-detector@1.9.0: {} + packet-reader@1.0.0: {} pad-right@0.2.2: @@ -6127,6 +10077,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.0 @@ -6134,14 +10094,29 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + parse-ms@3.0.0: {} parse-npm-tarball-url@3.0.0: dependencies: semver: 6.3.1 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -6165,11 +10140,13 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + pathval@1.1.1: {} pg-connection-string@2.12.0: {} - pg-connection-string@2.5.0: {} + pg-connection-string@2.6.2: {} pg-cursor@2.19.0(pg@8.9.0): dependencies: @@ -6210,10 +10187,14 @@ snapshots: dependencies: split2: 4.2.0 + piccolore@0.1.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} + picomatch@4.0.7: {} + pify@4.0.1: {} pino-abstract-transport@1.2.0: @@ -6261,6 +10242,12 @@ snapshots: dependencies: find-up: 4.1.0 + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + postgres-array@2.0.0: {} postgres-bytea@1.0.1: {} @@ -6273,10 +10260,16 @@ snapshots: prettier@2.8.8: {} + prettier@3.9.10: {} + pretty-ms@8.0.0: dependencies: parse-ms: 3.0.0 + prismjs@1.30.0: {} + + process-ancestry@0.1.0: {} + process-on-spawn@1.1.0: dependencies: fromentries: 1.3.2 @@ -6295,7 +10288,9 @@ snapshots: property-expr@2.0.6: {} - proxy-addr@2.0.7: + property-information@7.2.0: {} + + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -6321,6 +10316,8 @@ snapshots: quick-lru@4.0.1: {} + radix3@1.1.2: {} + ramda@0.28.0: {} range-parser@1.2.1: {} @@ -6355,7 +10352,7 @@ snapshots: read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.14.2 + js-yaml: 3.15.2 pify: 4.0.1 strip-bom: 3.0.0 @@ -6379,12 +10376,43 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.1.1: {} + real-require@0.2.0: {} rechoir@0.8.0: dependencies: resolve: 1.22.12 + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.19.0): + dependencies: + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -6403,16 +10431,94 @@ snapshots: reflect-metadata@0.2.1: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + regexp-match-indices@1.0.2: dependencies: regexp-tree: 0.1.27 regexp-tree@0.1.27: {} + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.6 + unified: 11.0.5 + release-zalgo@1.0.0: dependencies: es6-error: 4.1.1 + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.1.0 + micromark-util-types: 2.0.3 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.3: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.2.1 + unified: 11.0.5 + rename-overwrite@4.0.4: dependencies: '@zkochan/rimraf': 2.1.3 @@ -6420,6 +10526,10 @@ snapshots: repeat-string@1.6.1: {} + request-light@0.5.8: {} + + request-light@0.7.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -6434,6 +10544,8 @@ snapshots: dependencies: global-dirs: 0.1.1 + resolve-pkg-maps@1.0.0: {} + resolve-pkg@2.0.0: dependencies: resolve-from: 5.0.0 @@ -6450,6 +10562,31 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + reusify@1.1.0: {} reverse-arguments@1.0.0: {} @@ -6462,6 +10599,27 @@ snapshots: dependencies: glob: 7.2.3 + rolldown@1.2.16: + dependencies: + '@oxc-project/types': 0.156.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.16 + '@rolldown/binding-android-arm64': 1.2.16 + '@rolldown/binding-darwin-arm64': 1.2.16 + '@rolldown/binding-darwin-x64': 1.2.16 + '@rolldown/binding-freebsd-x64': 1.2.16 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.16 + '@rolldown/binding-linux-arm64-gnu': 1.2.16 + '@rolldown/binding-linux-arm64-musl': 1.2.16 + '@rolldown/binding-linux-ppc64-gnu': 1.2.16 + '@rolldown/binding-linux-s390x-gnu': 1.2.16 + '@rolldown/binding-linux-x64-gnu': 1.2.16 + '@rolldown/binding-linux-x64-musl': 1.2.16 + '@rolldown/binding-openharmony-arm64': 1.2.16 + '@rolldown/binding-win32-arm64-msvc': 1.2.16 + '@rolldown/binding-win32-x64-msvc': 1.2.16 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -6476,6 +10634,25 @@ snapshots: safer-buffer@2.1.2: {} + satteri@0.10.5: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.10.5 + '@bruits/satteri-darwin-x64': 0.10.5 + '@bruits/satteri-linux-arm64-gnu': 0.10.5 + '@bruits/satteri-linux-arm64-musl': 0.10.5 + '@bruits/satteri-linux-x64-gnu': 0.10.5 + '@bruits/satteri-linux-x64-musl': 0.10.5 + '@bruits/satteri-wasm32-wasi': 0.10.5 + '@bruits/satteri-win32-arm64-msvc': 0.10.5 + '@bruits/satteri-win32-x64-msvc': 0.10.5 + + sax@1.6.1: {} + secure-json-parse@4.1.0: {} seed-random@2.2.0: {} @@ -6494,6 +10671,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -6527,6 +10706,39 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.35.5(@types/node@24.12.2): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 24.12.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -6535,6 +10747,17 @@ snapshots: shell-quote-word@1.0.1: {} + shiki@4.6.0: + dependencies: + '@shikijs/core': 4.6.0 + '@shikijs/engine-javascript': 4.6.0 + '@shikijs/engine-oniguruma': 4.6.0 + '@shikijs/langs': 4.6.0 + '@shikijs/themes': 4.6.0 + '@shikijs/types': 4.6.0 + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -6587,6 +10810,8 @@ snapshots: slash@4.0.0: {} + smol-toml@1.9.1: {} + sonic-boom@3.8.1: dependencies: atomic-sleep: 1.0.0 @@ -6595,6 +10820,8 @@ snapshots: dependencies: atomic-sleep: 1.0.0 + source-map-js@1.2.2: {} + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -6602,6 +10829,12 @@ snapshots: source-map@0.6.1: {} + source-map@0.7.6: {} + + source-map@0.8.0: {} + + space-separated-tokens@2.0.2: {} + spawn-wrap@2.0.0: dependencies: foreground-child: 2.0.0 @@ -6646,13 +10879,15 @@ snapshots: statuses@2.0.2: {} + std-env@4.3.0: {} + stdin-discarder@0.3.2: {} - stream-chain@3.6.1: {} + stream-chain@4.2.6: {} - stream-json@2.1.0: + stream-json@3.7.0: dependencies: - stream-chain: 3.6.1 + stream-chain: 4.2.6 string-argv@0.3.1: {} @@ -6668,17 +10903,33 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string-width@8.2.0: dependencies: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string.fromcodepoint@0.2.1: {} string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -6703,8 +10954,22 @@ snapshots: strip-json-comments@5.0.3: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + style-to-object@2.0.2: + dependencies: + inline-style-parser: 0.2.9 + summary@2.1.0: {} + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6715,6 +10980,16 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + tarn@3.0.2: {} tcomb-validation@3.4.1: @@ -6755,6 +11030,19 @@ snapshots: tiny-case@1.0.3: {} + tiny-inflate@1.0.3: {} + + tinyclip@1.0.3: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyps@0.5.1: {} + tmp@0.2.5: {} to-no-case@1.0.2: {} @@ -6779,8 +11067,12 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + trim-newlines@3.0.1: {} + trough@2.2.0: {} + ts-dedent@2.2.0: {} ts-node-dev@1.1.8(typescript@5.7.3): @@ -6877,24 +11169,120 @@ snapshots: dependencies: is-typedarray: 1.0.0 + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.7.4 + typescript@5.7.3: {} + typescript@5.9.3: {} + + ufo@1.6.4: {} + + ultrahtml@1.7.0: {} + + uncrypto@0.1.3: {} + undici-types@7.16.0: {} + undici@7.29.1: {} + + undici@8.12.2: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unescape-js@1.1.4: dependencies: string.fromcodepoint: 0.2.1 + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@1.0.3: + dependencies: + css-tree: 3.2.1 + fnv1a-64: 0.1.2 + std-env: 4.3.0 + undici: 8.12.2 + unique-string@2.0.0: dependencies: crypto-random-string: 2.0.0 + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit-parents@6.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} universalify@2.0.1: {} unpipe@1.0.0: {} + unstorage@1.17.6: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.3 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -6932,17 +11320,160 @@ snapshots: vary@1.1.2: {} + verkit@0.5.0: {} + version-selector-type@3.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.3.5(@types/node@24.12.2)(esbuild@0.28.2)(jiti@1.21.0)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.30 + rolldown: 1.2.16 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.2 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 1.21.0 + yaml: 2.9.1 + + vitefu@1.1.3(vite@8.3.5(@types/node@24.12.2)(esbuild@0.28.2)(jiti@1.21.0)(yaml@2.9.1)): + optionalDependencies: + vite: 8.3.5(@types/node@24.12.2)(esbuild@0.28.2)(jiti@1.21.0)(yaml@2.9.1) vlq@0.2.3: {} + volar-service-css@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-emmet@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-html@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.15 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-prettier@0.0.71(@volar/language-service@2.4.28)(prettier@3.9.10): + dependencies: + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + prettier: 3.9.10 + + volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-typescript@0.0.71(@volar/language-service@2.4.28): + dependencies: + path-browserify: 1.0.1 + semver: 7.7.4 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.15 + vscode-nls: 5.2.0 + vscode-uri: 3.2.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-yaml@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.2.0 + yaml-language-server: 1.23.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.2.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-uri: 3.2.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-nls: 5.2.0 + vscode-uri: 3.2.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-jsonrpc@9.0.3: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-protocol@3.18.4: + dependencies: + vscode-jsonrpc: 9.0.3 + vscode-languageserver-types: 3.18.4 + + vscode-languageserver-textdocument@1.0.15: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver-types@3.18.4: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.2.0: {} + wcwidth@1.0.1: dependencies: defaults: 1.0.4 optional: true + web-namespaces@2.0.1: {} + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: @@ -6956,8 +11487,33 @@ snapshots: dependencies: isexe: 2.0.0 + workerd@1.20261012.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261012.1 + '@cloudflare/workerd-darwin-arm64': 1.20261012.1 + '@cloudflare/workerd-linux-64': 1.20261012.1 + '@cloudflare/workerd-linux-arm64': 1.20261012.1 + '@cloudflare/workerd-windows-64': 1.20261012.1 + workerpool@9.3.4: {} + wrangler@4.151.0(@types/node@24.12.2): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261012.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.2 + miniflare: 5.20261012.0-beta(@types/node@24.12.2) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20261012.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -6976,6 +11532,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} write-file-atomic@3.0.3: @@ -6991,6 +11553,8 @@ snapshots: xtend@4.0.2: {} + xxhash-wasm@1.1.0: {} + y18n@4.0.3: {} y18n@5.0.8: {} @@ -6999,8 +11563,25 @@ snapshots: yallist@4.0.0: {} + yaml-language-server@1.23.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) + ajv-i18n: 4.2.0(ajv@8.18.0) + prettier: 3.9.10 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.15 + vscode-languageserver-types: 3.18.4 + vscode-uri: 3.2.0 + yaml: 2.8.3 + yaml@2.8.3: {} + yaml@2.9.1: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 @@ -7010,6 +11591,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs-unparser@2.0.0: dependencies: camelcase: 6.3.0 @@ -7041,12 +11624,36 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.2.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.3.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yn@3.1.1: {} yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + yoctocolors@2.1.2: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + yup@1.2.0: dependencies: property-expr: 2.0.6 @@ -7061,3 +11668,7 @@ snapshots: zod@3.22.4: {} zod@3.25.76: {} + + zod@4.6.7: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..b788deddb --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +packages: + - "." + - website/site +onlyBuiltDependencies: + - esbuild + - node-av + - sharp + - skia-canvas + - webgpu diff --git a/resources/admin/assets/admin-moderation.js b/resources/admin/assets/admin-moderation.js new file mode 100644 index 000000000..78e55ad1f --- /dev/null +++ b/resources/admin/assets/admin-moderation.js @@ -0,0 +1,202 @@ +(() => { + const config = window.__ADMIN_DASHBOARD__ || { pathPrefix: '' } + const adminBase = `${config.pathPrefix || ''}/admin` + + const syncLine = document.getElementById('moderation-sync') + if (!syncLine) { + return + } + + const elements = { + error: document.getElementById('moderation-error'), + success: document.getElementById('moderation-success'), + syncLine, + banPubkeyInput: document.getElementById('moderation-ban-pubkey'), + ipInput: document.getElementById('moderation-ip'), + banButton: document.getElementById('moderation-ban-button'), + unbanButton: document.getElementById('moderation-unban-button'), + whitelistIpButton: document.getElementById('moderation-whitelist-ip-button'), + unwhitelistIpButton: document.getElementById('moderation-unwhitelist-ip-button'), + refreshButton: document.getElementById('moderation-refresh-button'), + topPubkeysBody: document.getElementById('moderation-top-pubkeys-body'), + topIpsBody: document.getElementById('moderation-top-ips-body'), + ratePubkeysBody: document.getElementById('moderation-rate-pubkeys-body'), + pubkeyBlacklist: document.getElementById('moderation-pubkey-blacklist'), + pubkeyWhitelist: document.getElementById('moderation-pubkey-whitelist'), + kindBlacklist: document.getElementById('moderation-kind-blacklist'), + kindWhitelist: document.getElementById('moderation-kind-whitelist'), + bypassPubkeys: document.getElementById('moderation-bypass-pubkeys'), + bypassIps: document.getElementById('moderation-bypass-ips'), + } + + const escapeHtml = (value) => + String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + + const adminFetch = async (path, options = {}) => { + const response = await fetch(`${adminBase}${path}`, { + credentials: 'same-origin', + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, + ...options, + }) + if (response.status === 401) { + throw new Error('Unauthorized') + } + return response + } + + const showError = (message) => { + elements.error.textContent = message + elements.error.classList.remove('d-none') + elements.success.classList.add('d-none') + } + + const showSuccess = (message) => { + elements.success.textContent = message + elements.success.classList.remove('d-none') + elements.error.classList.add('d-none') + } + + const hideMessages = () => { + elements.error.classList.add('d-none') + elements.success.classList.add('d-none') + } + + const handleUnauthorized = () => { + globalThis.dispatchEvent(new CustomEvent('admin-unauthorized')) + } + + const renderRows = (body, rows, keyField, valueField, emptyLabel) => { + if (!body) { + return + } + if (!Array.isArray(rows) || !rows.length) { + body.innerHTML = `${escapeHtml(emptyLabel)}` + return + } + body.innerHTML = rows + .map( + (row) => + `${escapeHtml(row[keyField])}${escapeHtml(row[valueField])}`, + ) + .join('') + } + + const renderRateRows = (body, rows) => { + if (!body) { + return + } + if (!Array.isArray(rows) || !rows.length) { + body.innerHTML = 'No rate-limit hits recorded yet.' + return + } + body.innerHTML = rows + .map((row) => `${escapeHtml(row.key)}${escapeHtml(row.hits)}`) + .join('') + } + + const renderLimits = (limits) => { + const pretty = (value) => JSON.stringify(value ?? [], null, 2) + elements.pubkeyBlacklist.textContent = pretty(limits.pubkey?.blacklist) + elements.pubkeyWhitelist.textContent = pretty(limits.pubkey?.whitelist) + elements.kindBlacklist.textContent = pretty(limits.kind?.blacklist) + elements.kindWhitelist.textContent = pretty(limits.kind?.whitelist) + elements.bypassPubkeys.textContent = pretty(limits.rateLimitBypass?.pubkeys) + elements.bypassIps.textContent = pretty(limits.rateLimitBypass?.ipAddresses) + } + + const load = async () => { + hideMessages() + try { + const [limitsResponse, statsResponse] = await Promise.all([ + adminFetch('/moderation/limits'), + adminFetch('/moderation/stats?windowHours=168&limit=15'), + ]) + + if (!limitsResponse.ok || !statsResponse.ok) { + throw new Error('Failed to load moderation data') + } + + const limitsPayload = await limitsResponse.json() + const statsPayload = await statsResponse.json() + renderLimits(limitsPayload.limits) + renderRows(elements.topPubkeysBody, statsPayload.topPublishingPubkeys, 'pubkey', 'eventCount', 'No events in window.') + renderRows(elements.topIpsBody, statsPayload.topClientIps, 'ip', 'eventCount', 'No client IPs in window.') + renderRateRows(elements.ratePubkeysBody, statsPayload.rateLimitHits?.pubkeys) + elements.syncLine.innerHTML = `> moderation: updated ${new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC')}` + } catch (error) { + if (error instanceof Error && error.message === 'Unauthorized') { + handleUnauthorized() + return + } + showError('Unable to load moderation data.') + } + } + + const postAction = async (body) => { + hideMessages() + const response = await adminFetch('/moderation/actions', { method: 'POST', body: JSON.stringify(body) }) + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(payload.error || 'Moderation action failed') + } + renderLimits(payload.limits) + showSuccess('Limits updated.') + elements.syncLine.innerHTML = `> moderation: action ${body.action} applied` + void load() + } + + const bindAction = (button, handler) => { + button?.addEventListener('click', () => { + void handler().catch((error) => { + if (error instanceof Error && error.message === 'Unauthorized') { + handleUnauthorized() + return + } + showError(error instanceof Error ? error.message : 'Action failed.') + }) + }) + } + + bindAction(elements.banButton, async () => { + const pubkey = elements.banPubkeyInput?.value?.trim() + if (!pubkey) { + throw new Error('Enter a pubkey or npub to ban.') + } + await postAction({ action: 'ban-pubkey', pubkey }) + }) + + bindAction(elements.unbanButton, async () => { + const pubkey = elements.banPubkeyInput?.value?.trim() + if (!pubkey) { + throw new Error('Enter a pubkey or npub to unban.') + } + await postAction({ action: 'unban-pubkey', pubkey }) + }) + + bindAction(elements.whitelistIpButton, async () => { + const ip = elements.ipInput?.value?.trim() + if (!ip) { + throw new Error('Enter an IP address.') + } + await postAction({ action: 'whitelist-ip', ip }) + }) + + bindAction(elements.unwhitelistIpButton, async () => { + const ip = elements.ipInput?.value?.trim() + if (!ip) { + throw new Error('Enter an IP address.') + } + await postAction({ action: 'unwhitelist-ip', ip }) + }) + + elements.refreshButton?.addEventListener('click', () => { + void load() + }) + + window.__ADMIN_MODERATION__ = { load } +})() diff --git a/resources/admin/assets/admin-ops.js b/resources/admin/assets/admin-ops.js new file mode 100644 index 000000000..db08005cb --- /dev/null +++ b/resources/admin/assets/admin-ops.js @@ -0,0 +1,345 @@ +(() => { + const config = window.__ADMIN_DASHBOARD__ || { pathPrefix: '' } + const adminBase = `${config.pathPrefix || ''}/admin` + + const workersBody = document.getElementById('ops-workers-body') + if (!workersBody) { + return + } + + const elements = { + workersSync: document.getElementById('ops-workers-sync'), + workersBody, + nip05Error: document.getElementById('ops-nip05-error'), + nip05Domain: document.getElementById('ops-nip05-domain'), + nip05Verified: document.getElementById('ops-nip05-verified'), + nip05Stale: document.getElementById('ops-nip05-stale'), + nip05MinFailures: document.getElementById('ops-nip05-min-failures'), + nip05Refresh: document.getElementById('ops-nip05-refresh'), + nip05Body: document.getElementById('ops-nip05-body'), + nip05Pagination: document.getElementById('ops-nip05-pagination'), + storageKinds: document.getElementById('ops-storage-kinds'), + storageSince: document.getElementById('ops-storage-since'), + storageUntil: document.getElementById('ops-storage-until'), + storageRun: document.getElementById('ops-storage-run'), + storageError: document.getElementById('ops-storage-error'), + storageBody: document.getElementById('ops-storage-body'), + } + + let pollTimer + let nip05Offset = 0 + const nip05Limit = 50 + + const formatTime = (iso) => { + if (!iso) { + return '—' + } + const date = new Date(iso) + if (Number.isNaN(date.getTime())) { + return iso + } + return date.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC') + } + + const shortPubkey = (pubkey) => (pubkey.length > 16 ? `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}` : pubkey) + + const escapeHtml = (value) => + String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + + const escapeAttr = (value) => escapeHtml(value).replaceAll('`', '`') + + const signalBadge = (stale) => { + if (stale) { + return 'Stale' + } + return 'Live' + } + + const workerSignalBadge = (worker) => { + if (worker.monitored === false) { + return 'Unmonitored' + } + return signalBadge(worker.stale) + } + + const adminFetch = async (path, options = {}) => { + const response = await fetch(`${adminBase}${path}`, { + credentials: 'same-origin', + ...options, + }) + if (response.status === 401) { + throw new Error('Unauthorized') + } + return response + } + + const renderWorkers = (workers) => { + if (!Array.isArray(workers) || !workers.length) { + elements.workersBody.innerHTML = + 'No workers configured.' + return + } + + elements.workersBody.innerHTML = workers + .map((worker) => { + const heartbeat = worker.heartbeat + const finished = formatTime(heartbeat?.lastRunFinishedAt ?? heartbeat?.updatedAt) + const lastError = heartbeat?.lastError ?? '—' + return ` + ${escapeHtml(worker.label)} + ${escapeHtml(finished)} + ${escapeHtml(lastError)} + ${workerSignalBadge(worker)} + ` + }) + .join('') + } + + const renderNip05 = (rows, pagination) => { + if (!Array.isArray(rows) || !rows.length) { + elements.nip05Body.innerHTML = 'No matching verifications.' + } else { + elements.nip05Body.innerHTML = rows + .map( + (row) => ` + ${escapeHtml(shortPubkey(row.pubkey))} + ${escapeHtml(row.nip05)} + ${row.isVerified ? 'yes' : 'no'} + ${escapeHtml(row.failureCount)} + ${escapeHtml(formatTime(row.lastVerifiedAt))} + ${row.stale ? 'yes' : 'no'} + + `, + ) + .join('') + } + + if (elements.nip05Pagination && pagination) { + const { offset, limit, total } = pagination + elements.nip05Pagination.replaceChildren() + + const summary = document.createElement('span') + summary.textContent = `Showing ${offset + 1}–${Math.min(offset + limit, total)} of ${total}` + elements.nip05Pagination.append(summary) + + if (total > limit) { + const controls = document.createElement('span') + controls.className = 'ms-2' + + const previous = document.createElement('button') + previous.type = 'button' + previous.className = 'btn btn-console btn-sm me-1' + previous.textContent = 'Previous' + previous.disabled = offset <= 0 + previous.addEventListener('click', () => { + nip05Offset = Math.max(0, offset - limit) + void loadNip05() + }) + + const next = document.createElement('button') + next.type = 'button' + next.className = 'btn btn-console btn-sm' + next.textContent = 'Next' + next.disabled = offset + limit >= total + next.addEventListener('click', () => { + nip05Offset = offset + limit + void loadNip05() + }) + + controls.append(previous, next) + elements.nip05Pagination.append(controls) + } + } + } + + const renderStorage = (counts) => { + if (!Array.isArray(counts) || !counts.length) { + elements.storageBody.innerHTML = + 'No counts returned.' + return + } + + elements.storageBody.innerHTML = counts + .map( + (row) => ` + ${escapeHtml((row.kinds ?? []).join(', '))} + ${escapeHtml(row.count)} + `, + ) + .join('') + } + + const hideNip05Error = () => elements.nip05Error?.classList.add('d-none') + const showNip05Error = (message) => { + if (!elements.nip05Error) { + return + } + elements.nip05Error.textContent = message + elements.nip05Error.classList.remove('d-none') + } + + const hideStorageError = () => elements.storageError?.classList.add('d-none') + const showStorageError = (message) => { + if (!elements.storageError) { + return + } + elements.storageError.textContent = message + elements.storageError.classList.remove('d-none') + } + + const loadWorkers = async () => { + const response = await adminFetch('/ops/workers') + if (!response.ok) { + throw new Error('Failed to load workers') + } + const payload = await response.json() + renderWorkers(payload.workers) + if (elements.workersSync) { + elements.workersSync.innerHTML = `> workers: updated ${formatTime(new Date().toISOString())}` + } + } + + const buildNip05Query = () => { + const params = new URLSearchParams() + params.set('limit', String(nip05Limit)) + params.set('offset', String(nip05Offset)) + + const domain = elements.nip05Domain?.value.trim() + if (domain) { + params.set('domain', domain) + } + + const verified = elements.nip05Verified?.value + if (verified) { + params.set('verified', verified) + } + + const stale = elements.nip05Stale?.value + if (stale) { + params.set('stale', stale) + } + + const minFailures = elements.nip05MinFailures?.value + if (minFailures !== undefined && minFailures !== '') { + params.set('minFailures', minFailures) + } + + return params.toString() + } + + const loadNip05 = async () => { + hideNip05Error() + const response = await adminFetch(`/ops/nip05-verifications?${buildNip05Query()}`) + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + showNip05Error(payload.error ?? 'Failed to load NIP-05 verifications') + return + } + renderNip05(payload.verifications, payload.pagination) + } + + const loadStorage = async () => { + hideStorageError() + const kinds = elements.storageKinds?.value.trim() + if (!kinds) { + showStorageError('Enter at least one kind.') + return + } + + const params = new URLSearchParams({ kinds }) + const since = elements.storageSince?.value + const until = elements.storageUntil?.value + if (since) { + params.set('since', since) + } + if (until) { + params.set('until', until) + } + + const response = await adminFetch(`/ops/storage/counts?${params.toString()}`) + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + showStorageError(payload.error ?? 'Storage count query failed') + return + } + renderStorage(payload.counts) + } + + const reverify = async (pubkey) => { + hideNip05Error() + const response = await adminFetch(`/ops/nip05-verifications/${encodeURIComponent(pubkey)}/reverify`, { + method: 'POST', + }) + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + showNip05Error(payload.error ?? 'Re-verification failed') + return + } + await loadNip05() + } + + const refreshAll = async () => { + try { + await Promise.all([loadWorkers(), loadNip05()]) + } catch { + showNip05Error('Unable to refresh operations data.') + } + } + + const stopPolling = () => { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = undefined + } + } + + const startPolling = () => { + stopPolling() + pollTimer = setInterval(() => { + void refreshAll() + }, 30_000) + } + + const resetNip05Filters = () => { + nip05Offset = 0 + void loadNip05() + } + + elements.nip05Refresh?.addEventListener('click', resetNip05Filters) + elements.nip05Domain?.addEventListener('change', resetNip05Filters) + elements.nip05Verified?.addEventListener('change', resetNip05Filters) + elements.nip05Stale?.addEventListener('change', resetNip05Filters) + elements.nip05MinFailures?.addEventListener('change', resetNip05Filters) + + elements.storageRun?.addEventListener('click', () => { + void loadStorage() + }) + + elements.nip05Body?.addEventListener('click', (event) => { + const target = event.target + if (!(target instanceof HTMLElement)) { + return + } + const pubkey = target.getAttribute('data-reverify-pubkey') + if (!pubkey) { + return + } + void reverify(pubkey) + }) + + window.__ADMIN_OPS__ = { + load: () => { + nip05Offset = 0 + void refreshAll() + startPolling() + }, + stop: () => { + stopPolling() + }, + } +})() diff --git a/resources/admin/assets/admin-trust.js b/resources/admin/assets/admin-trust.js new file mode 100644 index 000000000..c11668fa8 --- /dev/null +++ b/resources/admin/assets/admin-trust.js @@ -0,0 +1,215 @@ +(() => { + const config = window.__ADMIN_DASHBOARD__ || { pathPrefix: '' } + const adminBase = `${config.pathPrefix || ''}/admin` + + const auditBody = document.getElementById('trust-audit-body') + if (!auditBody) { + return + } + + const elements = { + auditSync: document.getElementById('trust-audit-sync'), + auditRefresh: document.getElementById('trust-audit-refresh'), + auditBody, + diagnosticsDownload: document.getElementById('trust-diagnostics-download'), + diagnosticsPreviewButton: document.getElementById('trust-diagnostics-preview-button'), + diagnosticsPreviewWrap: document.getElementById('trust-diagnostics-preview-wrap'), + diagnosticsPreviewClose: document.getElementById('trust-diagnostics-preview-close'), + diagnosticsPreviewPlaceholder: document.getElementById('trust-diagnostics-preview-placeholder'), + diagnosticsPreviewBody: document.getElementById('trust-diagnostics-preview-body'), + diagnosticsStatus: document.getElementById('trust-diagnostics-status'), + error: document.getElementById('trust-error'), + } + + const escapeHtml = (value) => + String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + + const formatTime = (iso) => { + if (!iso) { + return '—' + } + const date = new Date(iso) + if (Number.isNaN(date.getTime())) { + return iso + } + return date.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC') + } + + const adminFetch = async (path, options = {}) => { + const response = await fetch(`${adminBase}${path}`, { + credentials: 'same-origin', + ...options, + }) + if (response.status === 401) { + throw new Error('Unauthorized') + } + return response + } + + const showError = (message) => { + if (!elements.error) { + return + } + elements.error.textContent = message + elements.error.classList.remove('d-none') + } + + const hideError = () => { + elements.error?.classList.add('d-none') + } + + const handleUnauthorized = () => { + globalThis.dispatchEvent(new CustomEvent('admin-unauthorized')) + } + + const handleFetchError = (error, fallbackMessage) => { + if (error instanceof Error && error.message === 'Unauthorized') { + handleUnauthorized() + return + } + showError(fallbackMessage) + } + + const renderAuditEntries = (entries) => { + elements.auditBody.replaceChildren() + if (!Array.isArray(entries) || entries.length === 0) { + elements.auditBody.innerHTML = 'No audit entries yet.' + return + } + + entries.forEach((entry) => { + const row = document.createElement('tr') + const detail = + entry.detail && Object.keys(entry.detail).length > 0 + ? `${escapeHtml(JSON.stringify(entry.detail))}` + : '—' + row.innerHTML = ` + ${escapeHtml(formatTime(entry.at))} + ${escapeHtml(entry.action ?? '')} + ${escapeHtml(entry.remoteAddress ?? '—')} + ${detail} + ` + elements.auditBody.appendChild(row) + }) + } + + const loadAuditLog = async () => { + hideError() + if (elements.auditSync) { + elements.auditSync.textContent = '> audit: loading…' + } + + try { + const response = await adminFetch('/audit-log?limit=100') + if (!response.ok) { + showError('Failed to load audit log') + if (elements.auditSync) { + elements.auditSync.textContent = '> audit: load failed' + } + return + } + + const body = await response.json() + renderAuditEntries(body.entries ?? []) + if (elements.auditSync) { + elements.auditSync.textContent = `> audit: ${body.entries?.length ?? 0} entries` + } + } catch (error) { + handleFetchError(error, 'Network error while loading audit log') + } + } + + const downloadDiagnostics = async () => { + hideError() + if (elements.diagnosticsStatus) { + elements.diagnosticsStatus.textContent = 'Preparing export…' + } + + try { + const response = await adminFetch('/diagnostics/export?download=1') + if (!response.ok) { + showError('Diagnostics export failed') + return + } + + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `nostream-diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.json` + anchor.click() + URL.revokeObjectURL(url) + if (elements.diagnosticsStatus) { + elements.diagnosticsStatus.textContent = 'Download started (secrets redacted).' + } + } catch (error) { + handleFetchError(error, 'Network error during diagnostics export') + } + } + + const closeDiagnosticsPreview = () => { + elements.diagnosticsPreviewWrap?.classList.add('d-none') + elements.diagnosticsPreviewPlaceholder?.classList.remove('d-none') + if (elements.diagnosticsPreviewBody) { + elements.diagnosticsPreviewBody.textContent = '' + } + if (elements.diagnosticsStatus) { + elements.diagnosticsStatus.innerHTML = + '> diagnostics: idle' + } + } + + const showDiagnosticsPreview = (payload) => { + elements.diagnosticsPreviewPlaceholder?.classList.add('d-none') + elements.diagnosticsPreviewWrap?.classList.remove('d-none') + if (elements.diagnosticsPreviewBody) { + elements.diagnosticsPreviewBody.textContent = JSON.stringify(payload, null, 2) + } + if (elements.diagnosticsStatus) { + elements.diagnosticsStatus.textContent = `Generated ${payload.generatedAt ?? ''} · version ${payload.version ?? 'unknown'}` + } + } + + const previewDiagnostics = async () => { + hideError() + if (elements.diagnosticsStatus) { + elements.diagnosticsStatus.textContent = 'Loading preview…' + } + + try { + const response = await adminFetch('/diagnostics/export') + if (!response.ok) { + showError('Diagnostics preview failed') + return + } + + const payload = await response.json() + showDiagnosticsPreview(payload) + } catch (error) { + handleFetchError(error, 'Network error during diagnostics preview') + } + } + + elements.auditRefresh?.addEventListener('click', () => { + void loadAuditLog() + }) + elements.diagnosticsDownload?.addEventListener('click', () => { + void downloadDiagnostics() + }) + elements.diagnosticsPreviewButton?.addEventListener('click', () => { + void previewDiagnostics() + }) + elements.diagnosticsPreviewClose?.addEventListener('click', () => { + closeDiagnosticsPreview() + }) + + window.__ADMIN_TRUST__ = { + load: loadAuditLog, + closeDiagnosticsPreview, + } +})() diff --git a/resources/admin/assets/dashboard.css b/resources/admin/assets/dashboard.css index ba2f6dc5e..4e9873d71 100644 --- a/resources/admin/assets/dashboard.css +++ b/resources/admin/assets/dashboard.css @@ -1,45 +1,4 @@ -.admin-dashboard.console-theme { - --bg: #ece6dc; - --panel: #f6f2ec; - --panel-border: #b8aa98; - --text: #2c241c; - --text-muted: #6b5f52; - --label: #5a4f44; - --accent: #6b5344; - --prompt: #8a5a12; - --ok: #2f6b4f; - --warn: #8a5a12; - --danger: #8f3a3a; - --no-data: #5a6270; - min-height: 100vh; - background: var(--bg); - color: var(--text); - font-family: "IBM Plex Mono", "Courier New", monospace; - font-size: 0.875rem; -} - -.admin-dashboard.console-theme[data-theme="dark"] { - --bg: #0e120e; - --panel: #141a14; - --panel-border: #2e3e2e; - --text: #b8d8b8; - --text-muted: #6a906a; - --label: #8cb08c; - --accent: #7ecf7e; - --prompt: #9ecf6a; - --ok: #6ecf8a; - --warn: #d4b04a; - --danger: #e07a7a; - --no-data: #8aa4c8; - background-color: var(--bg); - background-image: repeating-linear-gradient( - 0deg, - transparent, - transparent 2px, - rgba(110, 207, 138, 0.02) 2px, - rgba(110, 207, 138, 0.02) 3px - ); -} +/* Admin console styles (nostream.dev black + pink). Edit resources/admin/assets/dashboard.css directly. */ .prompt-char { color: var(--prompt); @@ -338,7 +297,7 @@ } .admin-dashboard .console-input { - background: var(--bg); + background-color: var(--bg); border: 1px solid var(--panel-border); color: var(--text); font-family: inherit; @@ -346,7 +305,7 @@ } .admin-dashboard .console-input:focus { - background: var(--bg); + background-color: var(--bg); border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); color: var(--text); @@ -430,17 +389,13 @@ } .admin-dashboard .grafana-frame { - background: #141814; + background: var(--panel); border: 0; display: block; height: 380px; width: 100%; } -.admin-dashboard[data-theme="light"] .grafana-frame { - background: #1a1814; -} - .dashboard-view { width: 100%; } @@ -457,27 +412,27 @@ } .admin-dashboard .btn-settings-validate { - border-color: #5a8fd4; - color: #5a8fd4; + border-color: var(--teal); + color: var(--teal); } .admin-dashboard .btn-settings-validate:hover, .admin-dashboard .btn-settings-validate:focus { - background: color-mix(in srgb, #5a8fd4 12%, var(--panel)); - border-color: #5a8fd4; - color: #5a8fd4; + background: color-mix(in srgb, var(--teal) 12%, var(--panel)); + border-color: var(--teal); + color: var(--teal); } .admin-dashboard .btn-settings-preview { - border-color: #7eb8d4; - color: #7eb8d4; + border-color: var(--fg-2); + color: var(--fg-2); } .admin-dashboard .btn-settings-preview:hover, .admin-dashboard .btn-settings-preview:focus { - background: color-mix(in srgb, #7eb8d4 12%, var(--panel)); - border-color: #7eb8d4; - color: #7eb8d4; + background: color-mix(in srgb, var(--fg-2) 12%, var(--panel)); + border-color: var(--fg-2); + color: var(--fg-2); } .admin-dashboard .btn-settings-apply:not(:disabled) { @@ -754,6 +709,30 @@ } } +.network-health-timeline { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 12rem; + overflow-y: auto; +} + +.network-health-timeline-item { + background: var(--panel); + border: 1px solid var(--panel-border); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.72rem; + padding: 0.45rem 0.65rem; + text-align: left; + width: 100%; +} + +.network-health-timeline-item.is-selected { + border-color: var(--accent); + color: var(--text); +} + .network-health-results { display: grid; gap: 0.75rem; @@ -797,11 +776,13 @@ .network-health-checks { display: grid; gap: 0.45rem; - grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14.5rem), 1fr)); } .network-health-check { border: 1px solid var(--panel-border); + min-width: 0; + overflow: hidden; padding: 0.45rem 0.55rem; } @@ -817,6 +798,9 @@ .network-health-check-value { font-size: 0.78rem; margin-bottom: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; } .network-health-mismatches { @@ -846,7 +830,7 @@ } .network-health-mismatch-warning { - color: var(--status-degraded, #c9a227); + color: var(--status-degraded); } .network-health-mismatch-info { @@ -904,3 +888,955 @@ height: 300px; } } + +/* ===== theme: website/site/src/styles/admin-console-theme.css ===== */ + +/* + * nostream.dev admin console — black + pink only. + * Palette matches website/site/src/styles/global.css (#08080a / #ff2e88). + * Appended last by `pnpm sync:admin-theme` so these rules win. + */ + +.admin-dashboard.console-theme, +.admin-dashboard.console-theme[data-theme='light'], +.admin-dashboard.console-theme[data-theme='dark'] { + --bg: #08080a; + --bg-deep: #050506; + --panel: #0e0e11; + --panel-2: #111115; + --line: #26262a; + --panel-border: #2e2e33; + --wire: #48484f; + + --fg: #f7f7f7; + --fg-2: #a8a8a8; + --body: #b0b0b0; + --muted: #808080; + + --pink: #ff2e88; + --pink-hi: #ff4f9c; + --pink-soft: #ff8fc0; + --pink-dim: #6a1a3f; + --pink-wash: rgba(255, 46, 136, 0.1); + --pink-glow: rgba(255, 46, 136, 0.28); + + --text: var(--fg); + --text-muted: var(--muted); + --label: var(--fg-2); + --accent: var(--pink); + --prompt: var(--pink); + --ok: var(--fg); + --warn: var(--pink-soft); + --danger: var(--pink); + --no-data: var(--muted); + --status-degraded: var(--pink-soft); + --teal: var(--pink); + + --sans: 'Inter Variable', 'Inter', system-ui, -apple-system, sans-serif; + --mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace; + --font-mono: var(--mono); + --radius: 10px; + --radius-sm: 8px; + + --bs-body-bg: var(--bg); + --bs-body-color: var(--body); + --bs-border-color: var(--panel-border); + --bs-link-color: var(--fg); + --bs-link-hover-color: var(--pink); + --bs-primary: var(--pink); + --bs-success: var(--fg); + --bs-warning: var(--pink); + --bs-danger: var(--pink); + --bs-info: var(--pink); + + min-height: 100vh; + background: var(--bg); + color: var(--body); + font-family: var(--sans); + font-size: 15px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + position: relative; + color-scheme: dark; +} + +.admin-dashboard.console-theme::before { + content: ''; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.07) 1px, transparent 0); + background-size: 28px 28px; + mask-image: radial-gradient(ellipse 120% 80% at 50% 0%, #000 40%, transparent 100%); +} + +.admin-dashboard.console-theme > main { + max-width: 1320px; + margin-inline: auto; + position: relative; + z-index: 1; +} + +.admin-dashboard.console-theme .admin-spark-canvas { + display: block; + height: 100%; + inset: 0; + pointer-events: none; + position: fixed; + width: 100%; + z-index: 0; +} + +.admin-dashboard.console-theme.sparks-off .admin-spark-canvas { + display: none; +} + +.admin-dashboard.console-theme ::selection { + background: var(--pink); + color: #fff; +} + +.admin-dashboard.console-theme :focus-visible { + outline: 2px solid var(--pink); + outline-offset: 3px; + border-radius: 4px; +} + +.admin-dashboard.console-theme * { + scrollbar-color: var(--panel-border) transparent; + scrollbar-width: thin; +} + +/* ---------- type ---------- */ + +.admin-dashboard.console-theme .admin-kicker, +.admin-dashboard.console-theme .terminal-line, +.admin-dashboard.console-theme .module-code, +.admin-dashboard.console-theme .section-title, +.admin-dashboard.console-theme .metric-label, +.admin-dashboard.console-theme .field-label, +.admin-dashboard.console-theme .clock-value, +.admin-dashboard.console-theme .clock-label, +.admin-dashboard.console-theme .status-badge, +.admin-dashboard.console-theme .sync-line, +.admin-dashboard.console-theme .panel-header, +.admin-dashboard.console-theme .btn-console, +.admin-dashboard.console-theme .nav-item, +.admin-dashboard.console-theme .settings-category-button, +.admin-dashboard.console-theme .settings-diff-content, +.admin-dashboard.console-theme .network-health-timeline-item, +.admin-dashboard.console-theme code { + font-family: var(--mono); +} + +.admin-dashboard.console-theme .prompt-char { + color: var(--pink); +} + +.admin-dashboard.console-theme .admin-muted { + color: var(--muted); +} + +.admin-dashboard.console-theme code { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 5px; + color: var(--pink); + font-size: 0.86em; + padding: 1px 6px; +} + +/* ---------- header (website nav) ---------- */ + +.admin-dashboard.console-theme .admin-header { + background: var(--bg); + border-bottom: 1px solid var(--line); + margin: -1.5rem -0.75rem 2rem; + padding: 1.15rem 1.25rem; + position: sticky; + top: 0; + z-index: 1030; +} + +@media (min-width: 992px) { + .admin-dashboard.console-theme .admin-header { + margin-inline: -1.5rem; + padding-inline: 1.75rem; + } +} + +.admin-dashboard.console-theme .admin-kicker { + align-items: center; + color: var(--pink); + display: inline-flex; + font-size: 0.68rem; + font-weight: 600; + gap: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.admin-dashboard.console-theme .admin-kicker .prompt-char { + display: none; +} + +.admin-dashboard.console-theme .admin-kicker::before { + background: var(--pink); + content: ''; + height: 1px; + opacity: 0.7; + width: 22px; +} + +.admin-dashboard.console-theme .admin-title { + color: var(--fg); + font-family: var(--sans); + font-size: 1.45rem; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.1; +} + +.admin-dashboard.console-theme .clock-wrap { + align-items: baseline; + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 999px; + display: inline-flex; + gap: 0.5rem; + padding: 0.35rem 0.9rem; +} + +.admin-dashboard.console-theme .clock-value { + color: var(--fg); + font-size: 0.8rem; + font-variant-numeric: tabular-nums; +} + +.admin-dashboard.console-theme .clock-label { + color: var(--pink); + font-size: 0.58rem; + letter-spacing: 0.16em; +} + +/* ---------- section titles (website kickers) ---------- */ + +.admin-dashboard.console-theme .dashboard-section { + margin-bottom: 2.5rem; +} + +.admin-dashboard.console-theme .section-title { + align-items: center; + border-bottom: 0; + color: var(--fg-2); + display: flex; + font-size: 0.72rem; + font-weight: 600; + gap: 12px; + letter-spacing: 0.16em; + margin-bottom: 1.1rem; + padding-bottom: 0; + text-transform: uppercase; +} + +.admin-dashboard.console-theme .section-title::before { + background: var(--pink); + content: ''; + flex: none; + height: 1px; + opacity: 0.7; + width: 22px; +} + +.admin-dashboard.console-theme .section-title::after { + background: linear-gradient(90deg, var(--line), transparent); + content: ''; + flex: 1; + height: 1px; +} + +.admin-dashboard.console-theme .settings-diff-heading::before { + color: var(--pink); +} + +.admin-dashboard.console-theme .sync-line { + color: var(--muted); + font-size: 0.75rem; +} + +.admin-dashboard.console-theme .sync-cursor { + background: var(--pink); + border-radius: 1px; + box-shadow: 0 0 8px var(--pink-glow); +} + +/* ---------- surfaces (website panels / screens) ---------- */ + +.admin-dashboard.console-theme .panel-card, +.admin-dashboard.console-theme .metric-card, +.admin-dashboard.console-theme .network-health-target, +.admin-dashboard.console-theme .dashboard-nav { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: var(--radius); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.02), + 0 28px 70px -40px rgba(0, 0, 0, 0.85); +} + +.admin-dashboard.console-theme .panel-card, +.admin-dashboard.console-theme .network-health-target { + padding: 1.15rem 1.25rem; +} + +.admin-dashboard.console-theme .metric-card { + min-height: 6.25rem; + overflow: hidden; + padding: 1.1rem 1.2rem; + position: relative; + transition: + border-color 0.25s ease, + transform 0.25s ease, + background 0.25s ease; +} + +.admin-dashboard.console-theme .metric-card::before { + background: var(--pink); + content: ''; + height: 1px; + left: 12%; + opacity: 0; + position: absolute; + right: 12%; + top: 0; + transition: opacity 0.3s ease; +} + +.admin-dashboard.console-theme .metric-card:hover { + background: #111115; + border-color: var(--wire); + transform: translateY(-2px); +} + +.admin-dashboard.console-theme .metric-card:hover::before, +.admin-dashboard.console-theme .metric-card.updated::before { + opacity: 0.85; +} + +.admin-dashboard.console-theme .metric-card.updated { + border-color: var(--pink-dim); +} + +.admin-dashboard.console-theme .metric-label { + align-items: center; + color: var(--fg-2); + display: flex; + font-size: 0.68rem; + gap: 0.5rem; + letter-spacing: 0.1em; + margin-bottom: 0.75rem; +} + +.admin-dashboard.console-theme .module-code { + background: transparent; + border: 1px solid var(--pink-dim); + border-radius: 999px; + color: var(--pink); + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.1em; + margin-right: 0; + padding: 0.14rem 0.5rem; +} + +.admin-dashboard.console-theme .metric-value { + color: var(--fg); + font-family: var(--sans); + font-size: 1.55rem; + font-weight: 650; + letter-spacing: -0.03em; + line-height: 1.15; +} + +.admin-dashboard.console-theme .metric-unit { + color: var(--muted); + font-size: 0.82rem; + font-weight: 500; + letter-spacing: 0; +} + +.admin-dashboard.console-theme .panel-title { + color: var(--fg); + font-family: var(--sans); + font-weight: 650; + letter-spacing: -0.02em; +} + +.admin-dashboard.console-theme .panel-header { + align-items: center; + background: var(--panel); + border-bottom: 1px solid var(--line); + color: var(--muted); + display: flex; + font-size: 0.68rem; + gap: 0.5rem; + letter-spacing: 0.08em; + padding: 0.7rem 1rem; +} + +.admin-dashboard.console-theme .chart-panel { + overflow: hidden; + padding: 0; +} + +.admin-dashboard.console-theme .grafana-frame { + background: #000; +} + +/* ---------- status: pink intensity only ---------- */ + +.admin-dashboard.console-theme .status-led { + background: var(--wire); + height: 0.48rem; + margin-right: 0.5rem; + width: 0.48rem; +} + +.admin-dashboard.console-theme .status-ok, +.admin-dashboard.console-theme .settings-field-message.success, +.admin-dashboard.console-theme .notifications-status-ok { + color: var(--fg); +} + +.admin-dashboard.console-theme .status-ok .status-led, +.admin-dashboard.console-theme .status-badge.live .status-led { + animation: site-pulse 2.2s ease-in-out infinite; + background: var(--pink); + box-shadow: 0 0 0 3px var(--pink-wash), 0 0 10px var(--pink-glow); +} + +.admin-dashboard.console-theme .status-degraded, +.admin-dashboard.console-theme .settings-field-reload.restart, +.admin-dashboard.console-theme .network-health-mismatch-warning { + color: var(--pink-soft); +} + +.admin-dashboard.console-theme .status-degraded .status-led, +.admin-dashboard.console-theme .status-badge.warn .status-led { + animation: none; + background: var(--pink-soft); + box-shadow: none; +} + +.admin-dashboard.console-theme .status-unavailable, +.admin-dashboard.console-theme .status-down, +.admin-dashboard.console-theme .notifications-status-failed { + color: var(--pink); + font-weight: 650; +} + +.admin-dashboard.console-theme .status-unavailable .status-led, +.admin-dashboard.console-theme .status-down .status-led, +.admin-dashboard.console-theme .status-badge.error .status-led { + animation: none; + background: var(--pink); + box-shadow: 0 0 0 3px var(--pink-wash); +} + +.admin-dashboard.console-theme .metric-card:has(.status-unavailable), +.admin-dashboard.console-theme .metric-card:has(.status-down) { + background: linear-gradient(180deg, rgba(255, 46, 136, 0.08), var(--panel)); + border-color: var(--pink-dim); +} + +.admin-dashboard.console-theme .status-no-data { + color: var(--muted); +} + +.admin-dashboard.console-theme .status-no-data .status-led, +.admin-dashboard.console-theme .status-badge:not(.live):not(.warn):not(.error) .status-led { + animation: none; + background: var(--wire); + box-shadow: none; +} + +@keyframes site-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +.admin-dashboard.console-theme .status-badge { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 999px; + color: var(--fg-2); + font-size: 0.64rem; + letter-spacing: 0.12em; + padding: 0.35rem 0.8rem; +} + +.admin-dashboard.console-theme .status-badge.live { + border-color: var(--pink-dim); + color: var(--pink); +} + +.admin-dashboard.console-theme .status-badge.warn { + border-color: var(--pink-dim); + color: var(--pink-soft); +} + +.admin-dashboard.console-theme .status-badge.error { + background: var(--pink-wash); + border-color: var(--pink); + color: var(--pink); +} + +/* ---------- alerts ---------- */ + +.admin-dashboard.console-theme .system-alert, +.admin-dashboard.console-theme .alert-danger, +.admin-dashboard.console-theme .settings-validation { + background: linear-gradient(90deg, rgba(255, 46, 136, 0.12), transparent); + border: 1px solid var(--pink-dim); + border-left: 2px solid var(--pink); + border-radius: var(--radius-sm); + color: var(--pink-soft); + font-size: 0.82rem; + padding: 0.75rem 1rem; +} + +.admin-dashboard.console-theme .system-alert.unavailable, +.admin-dashboard.console-theme .alert-danger { + color: var(--pink); +} + +.admin-dashboard.console-theme .settings-success, +.admin-dashboard.console-theme .settings-validation.valid { + background: var(--panel); + border: 1px solid var(--panel-border); + border-left: 2px solid var(--pink); + border-radius: var(--radius-sm); + color: var(--fg); +} + +.admin-dashboard.console-theme .text-success { + color: var(--fg) !important; +} + +.admin-dashboard.console-theme .text-danger, +.admin-dashboard.console-theme .text-warning, +.admin-dashboard.console-theme .text-info, +.admin-dashboard.console-theme .text-primary { + color: var(--pink) !important; +} + +/* ---------- buttons (website .btn / .btn-primary) ---------- */ + +.admin-dashboard.console-theme .btn-console { + --bs-btn-padding-y: 0.45rem; + --bs-btn-padding-x: 0.95rem; + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: var(--radius-sm); + color: var(--fg); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + transition: + background 0.2s ease, + border-color 0.2s ease, + transform 0.2s ease; +} + +.admin-dashboard.console-theme .btn-console:hover, +.admin-dashboard.console-theme .btn-console:focus-visible { + background: var(--panel-2); + border-color: var(--wire); + color: var(--fg); + transform: translateY(-1px); +} + +.admin-dashboard.console-theme .btn-console:active { + transform: none; +} + +.admin-dashboard.console-theme .btn-console:disabled { + border-color: var(--line); + color: var(--muted); + opacity: 0.55; + transform: none; +} + +.admin-dashboard.console-theme .btn-console.copied { + border-color: var(--pink); + color: var(--pink); +} + +.admin-dashboard.console-theme #login-button, +.admin-dashboard.console-theme .btn-settings-apply:not(:disabled), +.admin-dashboard.console-theme #notifications-save-button { + background: var(--pink); + border-color: var(--pink); + box-shadow: 0 12px 32px -14px var(--pink-glow); + color: #fff; +} + +.admin-dashboard.console-theme #login-button:hover, +.admin-dashboard.console-theme .btn-settings-apply:not(:disabled):hover, +.admin-dashboard.console-theme #notifications-save-button:hover { + background: var(--pink-hi); + border-color: var(--pink-hi); + color: #fff; +} + +.admin-dashboard.console-theme #login-button { + height: 46px; + font-size: 0.82rem; +} + +.admin-dashboard.console-theme .btn-settings-validate, +.admin-dashboard.console-theme .btn-settings-preview, +.admin-dashboard.console-theme .btn-settings-restore, +.admin-dashboard.console-theme .btn-settings-reload { + border-color: var(--panel-border); + color: var(--fg); +} + +.admin-dashboard.console-theme .btn-settings-validate:hover, +.admin-dashboard.console-theme .btn-settings-preview:hover, +.admin-dashboard.console-theme .btn-settings-restore:hover, +.admin-dashboard.console-theme .btn-settings-reload:hover { + background: var(--panel-2); + border-color: var(--wire); + color: var(--fg); +} + +.admin-dashboard.console-theme .btn-settings-discard:not(:disabled) { + background: transparent; + border-color: var(--pink-dim); + color: var(--pink); +} + +.admin-dashboard.console-theme .btn-settings-discard:not(:disabled):hover { + background: var(--pink-wash); + border-color: var(--pink); + color: var(--pink); +} + +/* ---------- forms ---------- */ + +.admin-dashboard.console-theme .field-label { + color: var(--fg-2); + font-size: 0.68rem; + letter-spacing: 0.1em; +} + +.admin-dashboard.console-theme .console-input, +.admin-dashboard.console-theme .form-control, +.admin-dashboard.console-theme .form-select { + background-color: #000; + border: 1px solid var(--panel-border); + border-radius: var(--radius-sm); + color: var(--fg); + font-family: var(--mono); + font-size: 0.86rem; +} + +.admin-dashboard.console-theme .console-input::placeholder, +.admin-dashboard.console-theme .form-control::placeholder { + color: var(--muted); +} + +.admin-dashboard.console-theme .console-input:focus, +.admin-dashboard.console-theme .form-control:focus, +.admin-dashboard.console-theme .form-select:focus { + background-color: #000; + border-color: var(--pink); + box-shadow: 0 0 0 3px rgba(255, 46, 136, 0.16); + color: var(--fg); +} + +.admin-dashboard.console-theme .form-select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23a8a8a8' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 16px 12px; + padding-right: 2.25rem; +} + +.admin-dashboard.console-theme select.console-input.form-select:focus { + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 16px 12px; +} + +.admin-dashboard.console-theme .form-select option { + background: var(--panel); + color: var(--fg); +} + +.admin-dashboard.console-theme .form-check-input { + background-color: #000; + border: 1px solid var(--wire); +} + +.admin-dashboard.console-theme .form-check-input:focus { + border-color: var(--pink); + box-shadow: 0 0 0 3px rgba(255, 46, 136, 0.16); +} + +.admin-dashboard.console-theme .form-check-input:checked { + background-color: var(--pink); + border-color: var(--pink); +} + +.admin-dashboard.console-theme .form-switch .form-check-input { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23808080'/%3e%3c/svg%3e"); +} + +.admin-dashboard.console-theme .form-switch .form-check-input:focus:not(:checked) { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23ff2e88'/%3e%3c/svg%3e"); +} + +.admin-dashboard.console-theme .form-switch .form-check-input:checked { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); +} + +.admin-dashboard.console-theme .form-check-label { + color: var(--body); +} + +/* ---------- login ---------- */ + +.admin-dashboard.console-theme .login-box { + border-radius: 14px; + box-shadow: none; + margin-top: 4.5rem; + overflow: hidden; + padding: 2.1rem 2rem 1.9rem; + position: relative; +} + +.admin-dashboard.console-theme .login-box .panel-title { + font-size: 1.55rem; +} + +.admin-dashboard.console-theme .terminal-line { + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.04em; +} + +/* ---------- navigation ---------- */ + +.admin-dashboard.console-theme .dashboard-nav { + background: var(--panel); + border-radius: 14px; + min-width: 14rem; + padding: 1rem; + top: 5.4rem; +} + +.admin-dashboard.console-theme .nav-overlay { + background: rgba(0, 0, 0, 0.58); +} + +.admin-dashboard.console-theme .nav-item, +.admin-dashboard.console-theme .settings-category-button, +.admin-dashboard.console-theme .network-health-timeline-item { + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--fg-2); + font-size: 0.78rem; + padding: 0.55rem 0.8rem; + position: relative; + transition: + background 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; +} + +.admin-dashboard.console-theme .nav-item:hover, +.admin-dashboard.console-theme .settings-category-button:hover, +.admin-dashboard.console-theme .network-health-timeline-item:hover { + background: var(--panel); + color: var(--fg); +} + +.admin-dashboard.console-theme .nav-item.active, +.admin-dashboard.console-theme .settings-category-button.active, +.admin-dashboard.console-theme .network-health-timeline-item.is-selected { + background: var(--panel); + border-color: transparent; + box-shadow: inset 0 -1px 0 var(--pink); + color: var(--fg); +} + +.admin-dashboard.console-theme .nav-item.active::before, +.admin-dashboard.console-theme .settings-category-button.active::before, +.admin-dashboard.console-theme .network-health-timeline-item.is-selected::before { + display: none; +} + +.admin-dashboard.console-theme .network-health-timeline { + gap: 0.25rem; +} + +.admin-dashboard.console-theme .settings-category-button.has-changes::after { + color: var(--pink); +} + +/* ---------- network health ---------- */ + +.admin-dashboard.console-theme .network-health-target-url { + color: var(--fg); + font-family: var(--mono); + font-size: 0.86rem; +} + +.admin-dashboard.console-theme .network-health-network-type { + border: 1px solid var(--pink-dim); + border-radius: 999px; + color: var(--pink); + padding: 0.16rem 0.6rem; +} + +.admin-dashboard.console-theme .network-health-checks { + gap: 0.6rem; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); +} + +.admin-dashboard.console-theme .network-health-check { + background: #000; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + min-width: 0; + overflow: hidden; + padding: 0.75rem 0.85rem; +} + +.admin-dashboard.console-theme .network-health-check-label { + color: var(--muted); + font-family: var(--mono); + letter-spacing: 0.1em; +} + +.admin-dashboard.console-theme .network-health-check-value { + color: var(--body); + display: -webkit-box; + font-family: var(--mono); + font-size: 0.76rem; + line-height: 1.45; + max-width: 100%; + overflow: hidden; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + word-break: break-word; +} + +.admin-dashboard.console-theme .network-health-mismatches { + border-top: 1px solid var(--line); +} + +.admin-dashboard.console-theme .network-health-mismatch-heading { + color: var(--muted); + font-family: var(--mono); +} + +/* ---------- settings ---------- */ + +.admin-dashboard.console-theme .settings-toolbar { + border-bottom: 1px solid var(--line); + padding-bottom: 1rem; +} + +.admin-dashboard.console-theme .settings-field { + border-bottom: 1px solid var(--line); +} + +.admin-dashboard.console-theme .settings-field.changed { + border-left: 2px solid var(--pink); +} + +.admin-dashboard.console-theme .settings-field-path { + color: var(--muted); + font-family: var(--mono); +} + +.admin-dashboard.console-theme .settings-field-was, +.admin-dashboard.console-theme .settings-field-message.error { + color: var(--pink); +} + +.admin-dashboard.console-theme .settings-diff-header.highlight { + border-color: var(--pink); +} + +.admin-dashboard.console-theme .settings-diff-content { + background: #000; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 0.75rem; +} + +.admin-dashboard.console-theme .settings-diff-line.added { + color: var(--fg); +} + +.admin-dashboard.console-theme .settings-diff-line.removed { + color: var(--pink); +} + +.admin-dashboard.console-theme .settings-diff-line.meta, +.admin-dashboard.console-theme .settings-diff-summary, +.admin-dashboard.console-theme .settings-diff-note { + color: var(--muted); +} + +/* ---------- tables ---------- */ + +.admin-dashboard.console-theme .table { + --bs-table-bg: transparent; + --bs-table-color: var(--body); + --bs-table-border-color: var(--line); + --bs-table-hover-bg: var(--pink-wash); + --bs-table-hover-color: var(--fg); +} + +.admin-dashboard.console-theme .notifications-log-table thead th { + border-bottom-color: var(--panel-border); + color: var(--muted); + font-family: var(--mono); + font-weight: 600; + letter-spacing: 0.1em; +} + +.admin-dashboard.console-theme .notifications-log-table td, +.admin-dashboard.console-theme .notifications-log-table th { + border-color: var(--line); + padding: 0.65rem 0.8rem; +} + +.admin-dashboard.console-theme .notifications-advanced summary { + color: var(--fg-2); +} + +.admin-dashboard.console-theme .notifications-advanced[open] summary { + color: var(--pink); +} + +@media (prefers-reduced-motion: reduce) { + .admin-dashboard.console-theme *, + .admin-dashboard.console-theme *::before { + animation: none !important; + transition: none !important; + } +} diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js index 22b848486..9f5b88abf 100644 --- a/resources/admin/assets/dashboard.js +++ b/resources/admin/assets/dashboard.js @@ -13,7 +13,6 @@ const copySnapshotButton = document.getElementById('copy-snapshot-button') const streamStatus = document.getElementById('stream-status') const passwordInput = document.getElementById('password') - const themeToggle = document.getElementById('theme-toggle') const liveClock = document.getElementById('live-clock') const menuToggle = document.getElementById('menu-toggle') const navOverlay = document.getElementById('nav-overlay') @@ -40,6 +39,9 @@ const networkHealthSummary = document.getElementById('network-health-summary') const networkHealthResults = document.getElementById('network-health-results') const networkHealthRunAt = document.getElementById('network-health-run-at') + const networkHealthTimelineWrap = document.getElementById('network-health-timeline-wrap') + const networkHealthTimeline = document.getElementById('network-health-timeline') + const networkHealthRunButton = document.getElementById('network-health-run-button') let settingsLoaded = false let settingsLoading = false @@ -59,6 +61,10 @@ const staleThresholdMs = 15000 let networkHealthPollTimer const networkHealthPollIntervalMs = 60000 + const networkHealthHistoryLimit = 24 + let networkHealthHistoryRuns = [] + let selectedNetworkHealthRunAt = null + let networkHealthHistoryPinned = false const statusClasses = ['status-ok', 'status-degraded', 'status-unavailable', 'status-down', 'status-no-data'] @@ -100,20 +106,11 @@ }, } - const getTheme = () => { - const stored = localStorage.getItem('admin_theme') - if (stored === 'light' || stored === 'dark') { - return stored - } + // The console only ships the nostream.dev dark (black + pink) theme. + const getTheme = () => 'dark' - return 'light' - } - - const applyTheme = (theme) => { - document.body.setAttribute('data-theme', theme) - if (themeToggle) { - themeToggle.textContent = theme === 'dark' ? 'Dark' : 'Light' - } + const applyTheme = () => { + document.body.setAttribute('data-theme', 'dark') } const formatClock = () => { @@ -224,11 +221,22 @@ streamStatus.appendChild(document.createTextNode(label)) } - const updateSystemAlert = (snapshot) => { + let activeDashboardView = 'metrics-view' + + const hideSystemAlert = () => { if (!systemAlert) { return } + systemAlert.className = 'system-alert d-none' + systemAlert.textContent = '' + } + + const updateSystemAlert = (snapshot) => { + if (!systemAlert || activeDashboardView !== 'metrics-view') { + return + } + if (lastSnapshot?.timestamp && Date.now() - lastSnapshot.timestamp > staleThresholdMs) { systemAlert.textContent = '[WARN] Metrics stale — waiting for backend update.' systemAlert.className = 'system-alert degraded' @@ -315,9 +323,41 @@ return tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable } + const grafanaPanelUrl = (panelId) => { + const baseUrl = (config.grafanaBaseUrl || config.grafanaUrl || 'http://127.0.0.1:7777').replace(/\/+$/, '') + const dashboardUid = config.grafanaDashboardUid || 'nostream-overview' + const params = new URLSearchParams({ + orgId: '1', + panelId: String(panelId), + from: 'now-15m', + to: 'now', + refresh: '5s', + theme: 'dark', + kiosk: '1', + }) + return `${baseUrl}/d-solo/${dashboardUid}/nostream-overview?${params.toString()}` + } + + const loadGrafanaFrames = () => { + document.querySelectorAll('.grafana-frame').forEach((frame) => { + if (!frame.dataset.panelId || frame.src) { + return + } + frame.src = grafanaPanelUrl(frame.dataset.panelId) + }) + } + + const unloadGrafanaFrames = () => { + document.querySelectorAll('.grafana-frame').forEach((frame) => { + frame.removeAttribute('src') + }) + } + const showLogin = () => { + unloadGrafanaFrames() stopMetricsStream() stopNetworkHealthPolling() + window.__ADMIN_OPS__?.stop?.() setNavOpen(false) loginPanel.classList.remove('d-none') dashboardPanel.classList.add('d-none') @@ -327,14 +367,17 @@ loginError.classList.add('d-none') passwordInput.value = '' lastSnapshot = undefined + window.startAdminSparks?.() } const showDashboard = () => { + activeDashboardView = 'metrics-view' loginPanel.classList.add('d-none') dashboardPanel.classList.remove('d-none') dashboardActions.classList.remove('d-none') dashboardActions.classList.add('d-flex') menuToggle?.classList.remove('d-none') + window.stopAdminSparks?.() dashboardViews.forEach((view) => { view.classList.toggle('d-none', view.id !== 'metrics-view') @@ -343,24 +386,10 @@ item.classList.toggle('active', item.dataset.view === 'metrics-view') }) - document.querySelectorAll('.grafana-frame').forEach((frame) => { - if (!frame.src && frame.dataset.panelId) { - const theme = document.body.getAttribute('data-theme') === 'dark' ? 'dark' : 'light' - const baseUrl = (config.grafanaBaseUrl || config.grafanaUrl || 'http://127.0.0.1:7777').replace(/\/+$/, '') - const dashboardUid = config.grafanaDashboardUid || 'nostream-overview' - const params = new URLSearchParams({ - orgId: '1', - panelId: frame.dataset.panelId, - refresh: '5s', - theme, - }) - frame.src = `${baseUrl}/d-solo/${dashboardUid}?${params.toString()}` - } - }) - + loadGrafanaFrames() startMetricsStream() - void refreshNetworkHealth() - startNetworkHealthPolling() + stopNetworkHealthPolling() + window.__ADMIN_OPS__?.stop?.() } const stopNetworkHealthPolling = () => { @@ -374,6 +403,7 @@ stopNetworkHealthPolling() networkHealthPollTimer = setInterval(() => { void refreshNetworkHealth() + void refreshNetworkHealthHistory() }, networkHealthPollIntervalMs) } @@ -400,18 +430,123 @@ return 'status-no-data' } + const summarizeProbeError = (error) => { + const raw = String(error ?? '').replace(/\s+/g, ' ').trim() + if (!raw) { + return '' + } + + const lowered = raw.toLowerCase() + if (lowered.includes('wrong version number')) { + return 'wrong TLS version' + } + if (lowered.includes('certificate has expired')) { + return 'certificate expired' + } + if (lowered.includes('self-signed')) { + return 'self-signed certificate' + } + if (lowered.includes('unable to verify')) { + return 'certificate unverified' + } + if (lowered.includes('econnrefused')) { + return 'connection refused' + } + if (lowered.includes('etimedout') || lowered.includes('timeout')) { + return 'timed out' + } + if (lowered.includes('enotfound')) { + return 'host not found' + } + if (lowered.includes('cert') && lowered.includes('expired')) { + return 'certificate expired' + } + + const opensslReason = raw.match(/SSL routines:[^:]+:([^:\s]+(?:\s[^:]+)*)/i) + if (opensslReason?.[1]) { + return opensslReason[1].replace(/_/g, ' ') + } + + const lastSegment = raw.split(':').map((part) => part.trim()).filter(Boolean).at(-1) + if (lastSegment && lastSegment.length < 80 && !/^[0-9A-F]{6,}$/i.test(lastSegment)) { + return lastSegment + } + + return raw.length > 72 ? `${raw.slice(0, 69)}…` : raw + } + const formatProbeCheckDetail = (check, formatter, options = {}) => { const label = statusLabels.probeCheck[check?.status] ?? statusLabels.probeCheck.skipped - const detail = typeof formatter === 'function' && check?.status === 'ok' ? formatter(check.data) : check?.error + const rawDetail = typeof formatter === 'function' && check?.status === 'ok' ? formatter(check.data) : check?.error + const fullDetail = rawDetail ? String(rawDetail) : '' + const detail = check?.status === 'error' ? summarizeProbeError(fullDetail) : fullDetail const className = probeCheckStatusClass(check?.status, options) + const needsFullDetailTitle = + fullDetail && (fullDetail !== detail || fullDetail.length > 80) + return { label, className, - detail: detail ? String(detail) : '', + detail, + title: needsFullDetailTitle ? fullDetail : '', } } + const renderNetworkHealthTimeline = (runs) => { + if (!networkHealthTimeline || !networkHealthTimelineWrap) { + return + } + + networkHealthTimeline.replaceChildren() + + if (!Array.isArray(runs) || runs.length === 0) { + networkHealthTimelineWrap.classList.add('d-none') + return + } + + networkHealthTimelineWrap.classList.remove('d-none') + + runs.forEach((run) => { + const runAt = run?.runAt + const button = document.createElement('button') + button.type = 'button' + button.className = 'network-health-timeline-item' + button.setAttribute('role', 'listitem') + + const status = run?.status ?? 'failed' + const statusClass = + status === 'ok' ? 'status-ok' : status === 'partial' ? 'status-degraded' : 'status-down' + button.classList.add(statusClass) + + if (runAt && runAt === selectedNetworkHealthRunAt) { + button.classList.add('is-selected') + } + + const runAtMs = Date.parse(runAt ?? '') + const label = Number.isFinite(runAtMs) ? new Date(runAtMs).toISOString() : 'Unknown run' + const targetCount = Array.isArray(run?.results) ? run.results.length : 0 + button.textContent = `${label} · ${status} · ${targetCount} target(s)` + + button.addEventListener('click', () => { + if (networkHealthHistoryPinned && runAt && runAt === selectedNetworkHealthRunAt) { + networkHealthHistoryPinned = false + selectedNetworkHealthRunAt = null + renderNetworkHealthTimeline(networkHealthHistoryRuns) + void refreshNetworkHealth() + return + } + + networkHealthHistoryPinned = true + selectedNetworkHealthRunAt = runAt ?? null + renderNetworkHealthTimeline(networkHealthHistoryRuns) + renderNetworkHealthSnapshot(run) + }) + + networkHealthTimeline.appendChild(button) + }) + } + const renderNetworkHealthSnapshot = (snapshot) => { if (!networkHealthEmpty || !networkHealthSummary || !networkHealthResults) { return @@ -422,6 +557,9 @@ networkHealthSummary.classList.add('d-none') networkHealthResults.classList.add('d-none') networkHealthResults.replaceChildren() + if (networkHealthTimelineWrap) { + networkHealthTimelineWrap.classList.add('d-none') + } setNetworkHealthSyncLine('no probe snapshot available') return } @@ -563,6 +701,9 @@ const value = document.createElement('p') value.className = `network-health-check-value ${check.className} mb-0` value.textContent = check.detail ? `${check.label} · ${check.detail}` : check.label + if (check.title) { + value.title = check.title + } item.appendChild(label) item.appendChild(value) @@ -597,6 +738,42 @@ }) } + const runNetworkHealthProbe = async () => { + if (!networkHealthRunButton) { + return + } + + networkHealthRunButton.disabled = true + setNetworkHealthSyncLine('manual probe running…') + + try { + const response = await fetch(`${adminBase}/network-health/run`, { + method: 'POST', + credentials: 'include', + }) + + if (response.status === 401) { + showLogin() + return + } + + if (!response.ok) { + setNetworkHealthSyncLine('manual probe failed') + return + } + + networkHealthHistoryPinned = false + selectedNetworkHealthRunAt = null + await refreshNetworkHealth() + await refreshNetworkHealthHistory() + setNetworkHealthSyncLine('manual probe completed') + } catch { + setNetworkHealthSyncLine('network error during manual probe') + } finally { + networkHealthRunButton.disabled = false + } + } + const refreshNetworkHealth = async () => { try { const response = await fetch(`${adminBase}/network-health`, { @@ -614,12 +791,53 @@ } const body = await response.json() - renderNetworkHealthSnapshot(body.snapshot ?? null) + const snapshot = body.snapshot ?? null + let displaySnapshot = snapshot + if (networkHealthHistoryPinned && selectedNetworkHealthRunAt && networkHealthHistoryRuns.length > 0) { + const selected = networkHealthHistoryRuns.find((run) => run?.runAt === selectedNetworkHealthRunAt) + if (selected) { + displaySnapshot = selected + } else { + networkHealthHistoryPinned = false + selectedNetworkHealthRunAt = null + } + } + renderNetworkHealthSnapshot(displaySnapshot) + if (networkHealthHistoryRuns.length > 0) { + renderNetworkHealthTimeline(networkHealthHistoryRuns) + } } catch { setNetworkHealthSyncLine('network error while loading probes') } } + const refreshNetworkHealthHistory = async () => { + if (!networkHealthTimeline) { + return + } + + try { + const response = await fetch(`${adminBase}/network-health/history?limit=${networkHealthHistoryLimit}`, { + credentials: 'include', + }) + + if (response.status === 401) { + showLogin() + return + } + + if (!response.ok) { + return + } + + const body = await response.json() + networkHealthHistoryRuns = Array.isArray(body.runs) ? body.runs : [] + renderNetworkHealthTimeline(networkHealthHistoryRuns) + } catch { + // Timeline is optional; keep the latest snapshot panel usable. + } + } + const parsePathTokens = (path) => { const tokens = [] const pattern = /([^.[\]]+)|\[(\d+)\]/g @@ -1307,6 +1525,11 @@ } const showView = (viewId) => { + activeDashboardView = viewId + if (viewId !== 'metrics-view') { + hideSystemAlert() + } + dashboardViews.forEach((view) => { view.classList.toggle('d-none', view.id !== viewId) }) @@ -1316,16 +1539,49 @@ }) if (viewId === 'settings-view') { + stopNetworkHealthPolling() + window.__ADMIN_OPS__?.stop?.() + stopMetricsStream() void loadSettingsData() return } if (viewId === 'notifications-view') { + stopNetworkHealthPolling() + window.__ADMIN_OPS__?.stop?.() + stopMetricsStream() window.__ADMIN_NOTIFICATIONS__?.load?.() return } + if (viewId === 'operations-view') { + stopMetricsStream() + void refreshNetworkHealth() + void refreshNetworkHealthHistory() + startNetworkHealthPolling() + window.__ADMIN_OPS__?.load?.() + return + } + + if (viewId === 'trust-view') { + stopNetworkHealthPolling() + stopMetricsStream() + window.__ADMIN_OPS__?.stop?.() + window.__ADMIN_TRUST__?.load?.() + return + } + + if (viewId === 'moderation-view') { + stopNetworkHealthPolling() + stopMetricsStream() + window.__ADMIN_OPS__?.stop?.() + window.__ADMIN_MODERATION__?.load?.() + return + } + if (viewId === 'metrics-view') { + stopNetworkHealthPolling() + window.__ADMIN_OPS__?.stop?.() startMetricsStream() } } @@ -1335,26 +1591,6 @@ loginError.classList.remove('d-none') } - const refreshGrafanaFrames = () => { - const currentTheme = document.body.getAttribute('data-theme') === 'dark' ? 'dark' : 'light' - const baseUrl = (config.grafanaBaseUrl || config.grafanaUrl || 'http://127.0.0.1:7777').replace(/\/+$/, '') - const dashboardUid = config.grafanaDashboardUid || 'nostream-overview' - - document.querySelectorAll('.grafana-frame').forEach((frame) => { - if (!frame.src || !frame.dataset.panelId) { - return - } - - const params = new URLSearchParams({ - orgId: '1', - panelId: frame.dataset.panelId, - refresh: '5s', - theme: currentTheme, - }) - frame.src = `${baseUrl}/d-solo/${dashboardUid}?${params.toString()}` - }) - } - const updateSnapshot = (snapshot) => { lastSnapshot = snapshot @@ -1587,14 +1823,6 @@ return } - if (event.key === 't' || event.key === 'T') { - const current = document.body.getAttribute('data-theme') === 'dark' ? 'dark' : 'light' - const nextTheme = current === 'dark' ? 'light' : 'dark' - localStorage.setItem('admin_theme', nextTheme) - applyTheme(nextTheme) - refreshGrafanaFrames() - } - if ((event.key === 'l' || event.key === 'L') && !dashboardPanel.classList.contains('d-none')) { logoutButton?.click() } @@ -1639,15 +1867,9 @@ void loadSettingsData(true) }) - if (themeToggle) { - themeToggle.addEventListener('click', () => { - const current = document.body.getAttribute('data-theme') === 'dark' ? 'dark' : 'light' - const nextTheme = current === 'dark' ? 'light' : 'dark' - localStorage.setItem('admin_theme', nextTheme) - applyTheme(nextTheme) - refreshGrafanaFrames() - }) - } + networkHealthRunButton?.addEventListener('click', () => { + void runNetworkHealthProbe() + }) if (liveClock) { liveClock.textContent = formatClock() @@ -1656,6 +1878,11 @@ }, 1000) } + document.addEventListener('admin-unauthorized', () => { + window.__ADMIN_TRUST__?.closeDiagnosticsPreview?.() + showLogin() + }) + applyTheme(getTheme()) checkSession() })() diff --git a/resources/admin/assets/notifications.js b/resources/admin/assets/notifications.js index 48ab7fe28..8d60629a9 100644 --- a/resources/admin/assets/notifications.js +++ b/resources/admin/assets/notifications.js @@ -60,6 +60,9 @@ 'admission.invoice.failed': true, 'settings.changed': true, 'relay.restarted': false, + 'probe.failed': true, + 'probe.recovered': true, + 'ssl.expiring': true, }) const newTargetId = () => { @@ -74,6 +77,7 @@ type: 'http', enabled: true, url: '', + secret: '', botToken: '', chatId: '', }) @@ -109,6 +113,7 @@ const type = card.querySelector('[data-target-field="type"]')?.value ?? 'http' const enabled = card.querySelector('[data-target-field="enabled"]')?.checked ?? true const urlInput = card.querySelector('[data-target-field="url"]') + const secretInput = card.querySelector('[data-target-field="secret"]') const botTokenInput = card.querySelector('[data-target-field="botToken"]') const chatIdInput = card.querySelector('[data-target-field="chatId"]') @@ -121,6 +126,13 @@ target.url = REDACTED } + const secret = secretInput?.value?.trim() ?? '' + if (secret === REDACTED) { + target.secret = REDACTED + } else if (secretInput) { + target.secret = secret + } + const botToken = botTokenInput?.value?.trim() ?? '' if (botToken && botToken !== REDACTED) { target.botToken = botToken @@ -230,6 +242,20 @@ urlInput.value = target.url ?? '' urlCol.append(urlLabel, urlInput) + const secretCol = document.createElement('div') + secretCol.className = 'col-12 col-md-6 col-lg-3' + const secretLabel = document.createElement('label') + secretLabel.className = 'form-label field-label small mb-1' + secretLabel.textContent = 'Signing secret' + const secretInput = document.createElement('input') + secretInput.type = 'password' + secretInput.autocomplete = 'off' + secretInput.className = 'form-control console-input form-control-sm' + secretInput.dataset.targetField = 'secret' + secretInput.placeholder = 'HTTP HMAC (optional)' + secretInput.value = target.secret ?? '' + secretCol.append(secretLabel, secretInput) + const botCol = document.createElement('div') botCol.className = 'col-12 col-md-6 col-lg-3' const botLabel = document.createElement('label') @@ -256,7 +282,7 @@ chatInput.value = target.chatId ?? '' chatCol.append(chatLabel, chatInput) - fieldsRow.append(urlCol, botCol, chatCol) + fieldsRow.append(urlCol, secretCol, botCol, chatCol) card.append(header, statusEl, fieldsRow) removeButton.addEventListener('click', () => { diff --git a/resources/admin/assets/spark-field.js b/resources/admin/assets/spark-field.js new file mode 100644 index 000000000..970d63a61 --- /dev/null +++ b/resources/admin/assets/spark-field.js @@ -0,0 +1,144 @@ +(() => { + const prefersReduce = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches + + const start = (host) => { + const canvas = host.querySelector('[data-spark-canvas]') + if (!canvas || canvas.dataset.sparksRunning === '1' || prefersReduce()) { + return + } + + const ctx = canvas.getContext('2d', { alpha: true }) + if (!ctx) { + return + } + ctx.imageSmoothingEnabled = false + + const maxPool = Number(canvas.dataset.maxPool || 1800) + const spawnChance = Number(canvas.dataset.spawnChance || 0.99) + const perFrame = Number(canvas.dataset.perFrame || 6) + + let w = 0 + let h = 0 + let dpr = 1 + let raf = 0 + const pool = [] + + const resize = () => { + dpr = Math.min(window.devicePixelRatio || 1, 2) + w = Math.max(1, window.innerWidth) + h = Math.max(1, window.innerHeight) + canvas.width = Math.floor(w * dpr) + canvas.height = Math.floor(h * dpr) + canvas.style.width = `${w}px` + canvas.style.height = `${h}px` + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + } + + const leftStrip = () => Math.min(96, w * 0.12) + + const spawnStream = (edge = false) => { + const band = (Math.random() + Math.random() + Math.random()) / 3 + const speed = edge ? 0.28 + Math.random() * 1.05 : 0.95 + Math.random() * 2.6 + const strip = leftStrip() + pool.push({ + x: edge ? Math.random() * strip : -4 - Math.random() * 16, + y: h * (0.04 + band * 0.92), + vx: speed, + vy: (Math.random() - 0.5) * (edge ? 0.1 : 0.16), + life: 0, + max: edge ? 160 + Math.random() * 240 : (w * (0.55 + Math.random() * 0.5)) / speed, + phase: Math.random() * Math.PI * 2, + bright: Math.random() < (edge ? 0.7 : 0.52), + }) + } + + const tick = () => { + ctx.clearRect(0, 0, w, h) + for (let n = 0; n < perFrame; n++) { + if (pool.length < maxPool && Math.random() < spawnChance) { + spawnStream(Math.random() < 0.62) + } + } + if (pool.length < maxPool) { + if (Math.random() < 0.85) { + spawnStream(false) + } + for (let k = 0; k < 5; k++) { + if (pool.length < maxPool && Math.random() < 0.8) { + spawnStream(true) + } + } + } + + for (let i = pool.length - 1; i >= 0; i--) { + const p = pool[i] + p.life++ + p.x += p.vx + p.y += p.vy + Math.sin(p.life * 0.04 + p.phase) * 0.22 + + const t = p.life / p.max + let alpha = t < 0.08 ? t / 0.08 : t > 0.78 ? (1 - t) / 0.22 : 1 + alpha *= 0.7 + 0.45 * Math.abs(Math.sin(p.life * 0.13 + p.phase)) + if (p.x < leftStrip()) { + alpha = Math.min(1, alpha * 1.25) + } + + if (p.bright) { + ctx.fillStyle = `rgba(255,255,255,${Math.min(1, alpha)})` + } else { + const s = 246 + (i % 5) + ctx.fillStyle = `rgba(${s},${s + 1},255,${Math.min(1, alpha * 0.98)})` + } + + ctx.fillRect(Math.round(p.x), Math.round(p.y), 1, 1) + + if (p.life >= p.max || p.y < -20 || p.x > w + 10) { + pool.splice(i, 1) + } + } + + raf = requestAnimationFrame(tick) + } + + canvas.dataset.sparksRunning = '1' + resize() + tick() + + const onResize = () => resize() + window.addEventListener('resize', onResize) + + const stop = () => { + cancelAnimationFrame(raf) + window.removeEventListener('resize', onResize) + pool.length = 0 + ctx.clearRect(0, 0, w, h) + canvas.dataset.sparksRunning = '0' + } + + host.addEventListener('spark:dispose', stop, { once: true }) + } + + window.startAdminSparks = () => { + const host = document.querySelector('[data-spark-host]') + if (!host) { + return + } + host.classList.remove('sparks-off') + start(host) + } + + window.stopAdminSparks = () => { + const host = document.querySelector('[data-spark-host]') + if (!host) { + return + } + host.classList.add('sparks-off') + host.dispatchEvent(new Event('spark:dispose')) + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', window.startAdminSparks, { once: true }) + } else { + window.startAdminSparks() + } +})() diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html index 095cbf65b..ef046457c 100644 --- a/resources/admin/dashboard.html +++ b/resources/admin/dashboard.html @@ -4,13 +4,21 @@ {{name}} Admin - - - + + + - + - + +
@@ -21,7 +29,6 @@

{{name}}

-

--:--:--

UTC

@@ -47,7 +54,7 @@

Sign in

- +
@@ -58,6 +65,9 @@

Sign in

@@ -95,33 +105,6 @@

Health

-
-

Network Health

-

> probes: waiting for snapshot…

-
No external probe results yet. Enable NIP-66 relay monitoring to populate this panel.
-
-
-
-

RUN Probe status

-

Pending

-
-
-
-
-

TGT Targets

-

0

-
-
-
-
-

TS Last probe run

-

—

-
-
-
-
-
-

Throughput

@@ -221,6 +204,150 @@

Charts

+
+
+

Worker ops

+

> workers: not loaded

+
+ + + + + + + + + + + + + +
Background worker heartbeats
WorkerLast runLast errorSignal
No workers reported yet.
+
+
+ +
+
+

Tor / I2P & network probes

+ +
+

> probes: waiting for snapshot…

+
No external probe results yet. Enable NIP-66 relay monitoring to populate this panel.
+
+

HIST Recent probe runs

+
+
+
+
+
+

RUN Probe status

+

Pending

+
+
+
+
+

TGT Targets

+

0

+
+
+
+
+

TS Last probe run

+

—

+
+
+
+
+
+ +
+

NIP-05 verifications

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + +
NIP-05 verification records
PubkeyNIP-05VerifiedFailuresLast verifiedStale
No rows loaded.
+
+

+
+ +
+

Storage counts

+

Per-kind counts use NIP-45-style filters (comma-separated kinds, optional since/until unix seconds).

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + + + + + + + + + + +
Event storage counts by kind
KindsCount
Run a query to load counts.
+
+
+
+

Operator notifications

@@ -266,6 +393,24 @@

Operator notifications

+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
@@ -335,6 +480,153 @@

Delivery history

+
+
+

Diagnostics export

+

Download a redacted support bundle: version, health, settings, workers, and latest probe snapshot.

+ +
+ + +
+

> diagnostics: idle

+

Preview not loaded.

+
+
+

JSON Diagnostics preview

+ +
+

+            
+
+ +
+
+

Admin audit log

+ +
+

> audit: not loaded

+
+ + + + + + + + + + + + + +
Append-only admin audit entries
Time (UTC)ActionRemoteDetail
No entries loaded.
+
+
+
+ +
+
+

Quick actions

+

Ban or unban pubkeys and manage event rate-limit bypass lists without editing YAML by hand.

+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+

> moderation: not loaded

+
+ +
+
+

Activity (7 days)

+ +
+
+
+

Top publishing pubkeys

+
+ + + + + +
PubkeyEvents
Not loaded.
+
+
+
+

Top client IPs

+
+ + + + + +
IPEvents
Not loaded.
+
+
+
+

Rate-limit hits

+
+ + + + + +
PubkeyHits
Not loaded.
+
+
+
+
+ +
+

Current limits

+
+
+

Pubkey blacklist

+

+              
+
+

Pubkey whitelist

+

+              
+
+

Kind blacklist

+

+              
+
+

Kind whitelist

+

+              
+
+

Rate-limit bypass pubkeys

+

+              
+
+

Rate-limit bypass IPs

+

+              
+
+
+
+

Configuration

@@ -378,7 +670,11 @@

Preview diff

grafanaDashboardUid: '{{grafana_dashboard_uid}}', } + + + + diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 7c22934d7..de376ceaf 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -25,6 +25,14 @@ payments: - replace-with-your-pubkey-in-hex event_kinds: - 9735 # Nip-57 Lightning Zap Receipts + # Recurring membership plans. Leave empty to keep the one-time admission fee above. + # The amount paid selects the plan, so every plan needs its own amount. + # - id: basic + # description: Basic membership, paid every 30 days + # amount: 1000000 # msats per period + # intervalSeconds: 2592000 + # graceSeconds: 259200 # access lasts this long after a period ends unrenewed + subscriptionPlans: [] # Applied only when the payments processor does not report an expiry of its own. # Without it such invoices can never be retired and stay pending forever. # Raise it if your processor issues invoices that stay payable for longer than @@ -122,6 +130,9 @@ nip66: # Public WebSocket URLs to probe. Empty list defaults to info.relay_url. targets: [] dnsCacheTtlSeconds: 300 + historyRetentionRuns: 168 + # Optional max age in hours for probe history; 0 disables age-based trimming (run count only). + historyRetentionHours: 0 wot: # Web of Trust graph. When enabled, the relay builds a trust graph rooted # at seedPubkey from NIP-02 contact lists, updated in real time as kind-3 @@ -148,6 +159,52 @@ nip56: # When true, events matching an actionable report are excluded from # REQ/COUNT results. Has no effect unless enabled is also true. hideActionableReports: false +nip77: + # NIP-77 negentropy sync. Only answers when eventStore.backend is postgres. + enabled: false + # Most events a single session may hold in memory. + maxEventsPerSession: 100000 + # Concurrent negentropy sessions per connection. + maxSessionsPerConnection: 3 + # Close a session with no NEG-MSG for this long. + sessionIdleTimeoutMs: 60000 +peering: + # Relay peering advertisement, served as the `peering` section of the NIP-11 + # document. This is a discovery surface: it states the feeds and terms other + # relays can read. Enabling it does not accept peers by itself. + enabled: false + # How to ask for a peering relationship: a contact URI (mailto:, https:) and/or + # the WebSocket endpoint peers should connect to. At least one is required when + # peering is enabled. + contact: "" + endpoint: "" + # NIP-13 proof-of-work bits a peering request must carry. 0 means none. + # Advertised as peering.min_pow_difficulty. + minRequestPowDifficulty: 0 + # Feeds this relay is willing to serve. Each scope lists event kinds and, + # optionally, an absolute time range (unix seconds) or a rolling window. + # At least one scope is required when peering is enabled. + # Example: + # scopes: + # - kinds: [1, 3, 6, 7, 30023] + # - kinds: [[0, 9999]] + # since: 1700000000 + # - kinds: [1, 6] + # windowSeconds: 3600 + scopes: [] + # Terms for peering, reusing the NIP-11 fee-object shape. `period` is in + # seconds. An empty list means settlement-free peering. + # Example: [{ amount: 1000, unit: msats, period: 2592000 }] + terms: [] + # Peering peers trusted by their relay pubkey (hex or npub), never by URL + # alone. A connection that proves one of these keys with NIP-42 AUTH is + # recognized as a peer; any other key gets no peer recognition. + # Example: + # peers: + # - pubkey: replace-with-peer-relay-pubkey-in-hex + # relayUrl: wss://peer.example.com + # enabled: true + peers: [] network: maxPayloadSize: 524288 # The queued outbound payload bytes every socket of this relay shares. Unit: bytes; the guided @@ -170,15 +227,27 @@ workers: eventStore: # Only postgres for now. Changing it needs a restart. backend: postgres +# Kept because this branch still parses it (`NostreamSettings::mirroring`): main +# removed the worker and the key together, so the deletion arrived with the +# merge while the parser stayed. mirroring: static: [] dvm: workers: [] limits: + # Every `period` below is a count of milliseconds, and `rate` is how many hits + # one window allows. `period: 60`, written as if it meant 60 seconds, is read + # as a 60 ms window - which makes the limit a thousand times LOOSER, not + # stricter: twelve hits per minute becomes twelve hits per 60 ms. Nothing + # rejects it, because 60 is a valid count of milliseconds. The unit cannot be + # inferred from the number, so it is stated here and on RateLimit in + # src/@types/settings.ts. + # # strategy selection configuration for rate limiting: rateLimiter: strategy: ewma invoice: + # period is milliseconds (60000 = one minute) rateLimits: - period: 60000 rate: 12 @@ -191,11 +260,13 @@ limits: invite: # Per-pubkey limit on kind 28935 invite requests. Each mint writes a row to # invite_codes, so this is the main defence against a code flood. + # period is milliseconds (60000 = one minute) rateLimits: - description: 5 invite requests per hour per pubkey period: 3600000 rate: 5 admissionCheck: + # period is milliseconds (60000 = one minute) rateLimits: - description: 30 admission checks/min or 1 check every 2 seconds period: 60000 @@ -205,10 +276,12 @@ limits: - "10.10.10.1" - "::ffff:10.10.10.1" admin: + # period is milliseconds (60000 = one minute) rateLimits: - description: 30 admin requests/min period: 60000 rate: 30 + # period is milliseconds (60000 = one minute) loginRateLimits: - description: 10 login attempts per 15 minutes period: 900000 @@ -223,6 +296,7 @@ limits: # validator, and a value above 1099511627776 (about a million times any deployment) keeps the # default (ADR 0008). maxConnectionsPerIp: 64 + # period is milliseconds (60000 = one minute) rateLimits: - period: 1000 rate: 12 @@ -287,6 +361,7 @@ limits: - 39 - - 50 - 9007199254740991 + # period is milliseconds (60000 = one minute) rateLimits: - description: 6 events/min for event kinds 0, 3, 40 and 41 kinds: @@ -350,6 +425,7 @@ limits: maxLimit: 5000 minPrefixLength: 4 message: + # period is milliseconds (60000 = one minute) rateLimits: - description: 240 raw messages/min period: 60000 @@ -374,6 +450,9 @@ admin: admission.invoice.failed: true settings.changed: true relay.restarted: false + probe.failed: true + probe.recovered: true + ssl.expiring: true retry: maxAttempts: 5 baseDelayMs: 1000 diff --git a/resources/favicon.ico b/resources/favicon.ico index e21d60ebc..ff8ff2a9f 100644 Binary files a/resources/favicon.ico and b/resources/favicon.ico differ diff --git a/resources/get-invoice.html b/resources/get-invoice.html index 9682a0c77..4f85b7073 100644 --- a/resources/get-invoice.html +++ b/resources/get-invoice.html @@ -20,9 +20,7 @@

{{name}}

- This Nostr relay requires a one-time - admission - fee.
Once payment is complete, you will be able to post and fetch events from this relay. + {{fee_description_html}}

@@ -36,6 +34,7 @@

{{name}}

+ {{plan_fields_html}}
@@ -55,7 +54,7 @@

{{name}}

- +
diff --git a/resources/post-invoice.html b/resources/post-invoice.html index 119d07501..cfbad1e87 100644 --- a/resources/post-invoice.html +++ b/resources/post-invoice.html @@ -83,6 +83,7 @@

Invoice expired!

+
diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index f84f542cf..cd6cb96f9 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:stream' +import { INegentropySessions } from './negentropy' import { SubscriptionFilter } from './subscription' export interface IWebSocketServerAdapter extends EventEmitter, IWebServerAdapter { @@ -19,7 +20,17 @@ export type IWebSocketAdapter = EventEmitter & { getAuthenticatedPubkeys(): ReadonlySet /** Returns false if this AUTH event id was already accepted on this socket. */ addAuthenticatedPubkey(pubkey: string, authEventId: string): boolean + /** + * Configured peering peer proven by NIP-42 AUTH on this socket, or undefined. + * Clears with the AUTH session, so an expired/closed session cannot keep peer + * treatment. + */ + getPeerPubkey(): string | undefined + /** Mark `pubkey` as the peer identity for this socket. Ignored if it is not authenticated. */ + setPeerPubkey(pubkey: string): void drainAndClose(reason?: string): void + /** NIP-77 sessions of this connection. */ + getNegentropySessions(): INegentropySessions } export interface ICacheAdapter { @@ -29,6 +40,7 @@ export interface ICacheAdapter { setKeyIfNotExists(key: string, value: string, expirySeconds?: number): Promise addToSortedSet(key: string, set: Record | Record[]): Promise removeRangeByScoreFromSortedSet(key: string, min: number, max: number): Promise + removeRangeByRankFromSortedSet(key: string, start: number, stop: number): Promise getRangeFromSortedSet(key: string, start: number, stop: number): Promise setKeyExpiry(key: string, expiry: number): Promise diff --git a/src/@types/admin-audit.ts b/src/@types/admin-audit.ts new file mode 100644 index 000000000..707d6e85b --- /dev/null +++ b/src/@types/admin-audit.ts @@ -0,0 +1,22 @@ +export type AdminAuditAction = + | 'admin.login' + | 'admin.logout' + | 'settings.updated' + | 'settings.restored' + | 'notifications.updated' + | 'probe.manual_run' + | 'nip05.reverify' + | 'moderation.limits.updated' + | 'moderation.action' + +export interface AdminAuditEntry { + id: string + at: string + action: AdminAuditAction + remoteAddress?: string + detail?: Record +} + +export const ADMIN_AUDIT_LOG_KEY = 'admin:audit:log' +export const MAX_ADMIN_AUDIT_ENTRIES = 2000 +export const MAX_ADMIN_AUDIT_API_LIMIT = 500 diff --git a/src/@types/admin-ops.ts b/src/@types/admin-ops.ts new file mode 100644 index 000000000..1c8e19263 --- /dev/null +++ b/src/@types/admin-ops.ts @@ -0,0 +1,37 @@ +export interface AdminOpsWorkerHeartbeat { + type: string + index?: string + lastRunStartedAt?: string + lastRunFinishedAt?: string + lastError?: string + updatedAt: string +} + +export interface AdminOpsConfiguredWorker { + type: string + index?: string + enabled: boolean + label: string +} + +export interface AdminOpsWorkerRow extends AdminOpsConfiguredWorker { + heartbeat?: AdminOpsWorkerHeartbeat + monitored: boolean + stale: boolean +} + +export interface AdminNip05VerificationAdminRow { + pubkey: string + nip05: string + domain: string + isVerified: boolean + failureCount: number + lastVerifiedAt: string | null + lastCheckedAt: string + stale: boolean +} + +export interface AdminOpsStorageCountRow { + kinds: number[] + count: number +} diff --git a/src/@types/invoice.ts b/src/@types/invoice.ts index 3b94ab99b..6ca842785 100644 --- a/src/@types/invoice.ts +++ b/src/@types/invoice.ts @@ -26,6 +26,10 @@ export interface Invoice { updatedAt: Date createdAt: Date verifyURL?: string + /** Subscription plan this invoice pays for, when it is not an admission fee. */ + planId?: string | null + periodStart?: Date | null + periodEnd?: Date | null } export interface LnurlInvoice extends Invoice { @@ -46,4 +50,7 @@ export interface DBInvoice { updated_at: Date created_at: Date verify_url: string + plan_id?: string | null + period_start?: Date | null + period_end?: Date | null } diff --git a/src/@types/messages.ts b/src/@types/messages.ts index 591d5d94c..312e3b48f 100644 --- a/src/@types/messages.ts +++ b/src/@types/messages.ts @@ -13,13 +13,35 @@ export enum MessageType { COUNT = 'COUNT', CLOSED = 'CLOSED', AUTH = 'AUTH', + NEG_OPEN = 'NEG-OPEN', + NEG_MSG = 'NEG-MSG', + NEG_CLOSE = 'NEG-CLOSE', + NEG_ERR = 'NEG-ERR', } -export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage | CountMessage | AuthMessage) & { +export type IncomingMessage = ( + | SubscribeMessage + | IncomingEventMessage + | UnsubscribeMessage + | CountMessage + | AuthMessage + | NegOpenMessage + | NegMsgMessage + | NegCloseMessage +) & { [ContextMetadataKey]?: ContextMetadata } -export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult | CountResultMessage | ClosedMessage | AuthChallengeMessage +export type OutgoingMessage = + | OutgoingEventMessage + | EndOfStoredEventsNotice + | NoticeMessage + | CommandResult + | CountResultMessage + | ClosedMessage + | AuthChallengeMessage + | NegMsgMessage + | NegErrMessage export type SubscribeMessage = { [index in Range<2, 100>]: SubscriptionFilter @@ -103,3 +125,32 @@ export interface AuthChallengeMessage { 0: MessageType.AUTH 1: string } + +// NIP-77 +export interface NegOpenMessage { + 0: MessageType.NEG_OPEN + 1: SubscriptionId + 2: SubscriptionFilter + /** Hex-encoded initial negentropy message. */ + 3: string +} + +/** Sent in both directions; the payload is a hex-encoded negentropy message. */ +export interface NegMsgMessage { + 0: MessageType.NEG_MSG + 1: SubscriptionId + 2: string +} + +export interface NegCloseMessage { + 0: MessageType.NEG_CLOSE + 1: SubscriptionId +} + +export interface NegErrMessage { + 0: MessageType.NEG_ERR + 1: SubscriptionId + 2: string + /** For `blocked:` replies, the most records a session may hold. */ + 3?: number +} diff --git a/src/@types/negentropy.ts b/src/@types/negentropy.ts new file mode 100644 index 000000000..011fe9a81 --- /dev/null +++ b/src/@types/negentropy.ts @@ -0,0 +1,42 @@ +import { Negentropy } from '../utils/negentropy' +import { SubscriptionFilter, SubscriptionId } from './subscription' + +/** Proves a caller still owns a subscription id after awaiting; see `INegentropySessions.reserve`. */ +export type NegentropySessionToken = symbol + +export interface NegentropySession { + negentropy: Negentropy + /** The filter the session was opened with, so access can be re-checked on every message. */ + filter: SubscriptionFilter +} + +export interface INegentropySessions { + /** + * Claims `subscriptionId` for a new session, closing any session already open under that id. + * Returns undefined when the connection already holds `maxLimit` other sessions, or already has + * `maxLimit` event reads in flight -- including reads of requests that have since been replaced, + * which keep running until `endRead` is called. The caller must call `endRead` for a token it got. + */ + reserve(subscriptionId: SubscriptionId, maxLimit: number): NegentropySessionToken | undefined + /** Marks the event read started for a reservation as finished, successfully or not. */ + endRead(token: NegentropySessionToken): void + /** Whether `token` is still the reservation for `subscriptionId`, i.e. nobody replaced or closed it. */ + isCurrent(subscriptionId: SubscriptionId, token: NegentropySessionToken): boolean + /** + * Turns a reservation into a live session. Returns false when the reservation was closed or + * replaced while the caller was busy (e.g. reading events), in which case nothing is stored. + */ + activate( + subscriptionId: SubscriptionId, + token: NegentropySessionToken, + session: NegentropySession, + idleTimeoutMs: number, + ): boolean + /** The live session for an id, with its idle timer restarted. */ + touch(subscriptionId: SubscriptionId): NegentropySession | undefined + /** Drops a reservation that never became a session; a no-op if it has since been replaced. */ + release(subscriptionId: SubscriptionId, token: NegentropySessionToken): void + close(subscriptionId: SubscriptionId): boolean + closeAll(): void + getSessionIds(): SubscriptionId[] +} diff --git a/src/@types/operator-notifications.ts b/src/@types/operator-notifications.ts index 037963b82..880dfe4f2 100644 --- a/src/@types/operator-notifications.ts +++ b/src/@types/operator-notifications.ts @@ -4,6 +4,9 @@ export enum OperatorNotificationEventType { ADMISSION_INVOICE_FAILED = 'admission.invoice.failed', SETTINGS_CHANGED = 'settings.changed', RELAY_RESTARTED = 'relay.restarted', + PROBE_FAILED = 'probe.failed', + PROBE_RECOVERED = 'probe.recovered', + SSL_EXPIRING = 'ssl.expiring', } export type OperatorNotificationChannelType = 'http' | 'discord' | 'slack' | 'telegram' @@ -13,6 +16,8 @@ export interface OperatorNotificationTarget { type: OperatorNotificationChannelType enabled: boolean url?: string + /** HMAC-SHA256 signing secret for HTTP webhook payloads (optional). */ + secret?: string botToken?: string chatId?: string } @@ -23,6 +28,9 @@ export interface OperatorNotificationEventToggles { 'admission.invoice.failed'?: boolean 'settings.changed'?: boolean 'relay.restarted'?: boolean + 'probe.failed'?: boolean + 'probe.recovered'?: boolean + 'ssl.expiring'?: boolean } export interface OperatorNotificationRetrySettings { diff --git a/src/@types/relay-probe-snapshot.ts b/src/@types/relay-probe-snapshot.ts index 51f23d693..3e12df6f5 100644 --- a/src/@types/relay-probe-snapshot.ts +++ b/src/@types/relay-probe-snapshot.ts @@ -47,6 +47,11 @@ export interface RelayProbeRunSnapshot { probeContext?: RelayProbeRunContext } +export interface RelayProbeHistoryRetention { + maxRuns: number + maxAgeHours?: number +} + export interface IRelayProbeSnapshotStore { saveLatest(snapshot: RelayProbeRunSnapshot, expirySeconds?: number): Promise getLatest(): Promise diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 7d45cf102..e64c2873e 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -16,9 +16,12 @@ import { Report } from './report' import { EventKindsRange } from './settings' import { SubscriptionFilter } from './subscription' import { User } from './user' +import { UserSubscription } from './user-subscription' export interface EventRetentionOptions { maxDays?: number + /** Retention in days for subscribers on these plans, replacing `maxDays` for them while their access lasts. */ + planRetention?: { planId: string; maxDays: number }[] kindWhitelist?: (EventKinds | EventKindsRange)[] pubkeyWhitelist?: Pubkey[] } @@ -43,8 +46,8 @@ export interface IEventRepository { upsert(event: Event): Promise upsertMany(events: Event[]): Promise findByFilters(filters: SubscriptionFilter[]): IQueryResult - countByFilters(filters: SubscriptionFilter[]): Promise - findReconciliationItems(filter: SubscriptionFilter): IQueryResult + countByFilters(filters: SubscriptionFilter[], options?: { timeoutMs?: number }): Promise + findReconciliationItems(filter: SubscriptionFilter, limit?: number): IQueryResult deleteByPubkeyAndIds(pubkey: Pubkey, ids: EventId[]): Promise deleteByPubkeyExceptKinds(pubkey: Pubkey, excludedKinds: number[]): Promise hasActiveRequestToVanish(pubkey: Pubkey): Promise @@ -59,6 +62,30 @@ export interface IInvoiceRepository { findPendingInvoices(offset?: number, limit?: number, client?: DatabaseClient): Promise } +export interface IUserSubscriptionRepository { + findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise + upsert(subscription: Omit, client?: DatabaseClient): Promise + /** + * Applies `changes` only while the stored row still matches `expected` (status, plan, period end + * and grace end), so a payment confirmed after the row was read is not overwritten. + * Returns false when the row has changed. + */ + updateIfCurrent( + expected: UserSubscription, + changes: Partial>, + client?: DatabaseClient, + ): Promise + /** + * Subscriptions whose period ends by `before` and that need a renewal invoice: active ones, and + * renewal-pending ones whose invoice has expired unpaid. `offset` skips rows already tried. + */ + findDueForRenewal(before: Date, limit?: number, offset?: number, client?: DatabaseClient): Promise + /** Active or renewal-pending subscriptions whose period has ended by `now`. */ + findPeriodEnded(now: Date, limit?: number, offset?: number, client?: DatabaseClient): Promise + /** Past-due subscriptions whose grace period has ended by `now`. */ + findGraceEnded(now: Date, limit?: number, offset?: number, client?: DatabaseClient): Promise +} + export interface IUserRepository { findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise upsert(user: Partial, client?: DatabaseClient): Promise @@ -69,10 +96,21 @@ export interface IUserRepository { revokeAdmission(pubkey: Pubkey, client?: DatabaseClient): Promise } +export interface Nip05VerificationAdminListQuery { + domain?: string + verified?: boolean + stale?: boolean + minFailures?: number + verifyExpirationMs: number + limit: number + offset: number +} + export interface INip05VerificationRepository { findByPubkey(pubkey: Pubkey): Promise upsert(verification: Nip05Verification): Promise findPendingVerifications(updateFrequencyMs: number, maxFailures: number, limit: number): Promise + listForAdmin(query: Nip05VerificationAdminListQuery): Promise<{ rows: Nip05Verification[]; total: number }> deleteByPubkey(pubkey: Pubkey): Promise } diff --git a/src/@types/services.ts b/src/@types/services.ts index 652b453ea..21a04c177 100644 --- a/src/@types/services.ts +++ b/src/@types/services.ts @@ -27,14 +27,24 @@ export interface IWotGraphService { export interface IPaymentsService { getInvoiceFromPaymentsProcessor(invoice: string | Invoice): Promise> - createInvoice(pubkey: Pubkey, amount: bigint, description: string): Promise + createInvoice(pubkey: Pubkey, amount: bigint, description: string, planId?: string): Promise updateInvoice(invoice: Partial): Promise updateInvoiceStatus(invoice: Pick): Promise - confirmInvoice(invoice: Pick): Promise + confirmInvoice( + invoice: Pick & + Partial>, + ): Promise sendInvoiceUpdateNotification(invoice: Invoice): Promise + /** Tells the subscriber, with a kind 402 event, that their renewal invoice can be paid. */ + sendRenewalNotification(invoice: Invoice): Promise getPendingInvoices(offset?: number): Promise } +export interface ISubscriptionService { + /** One pass of renewal invoices, period ends and grace expiries. Returns how many subscriptions changed. */ + processLifecycle(now?: Date): Promise +} + export interface NotificationDispatchContext { outboxId?: string attemptNumber?: number diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 885ecdb11..eaf5c71bd 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -24,6 +24,18 @@ export interface Network { export interface RateLimit { description?: string + /** + * Length of the window in milliseconds. + * + * Every shipped limit counts this way (`1000`, `60000`, `900000`, `3600000`), + * and `rate` is how many hits one window allows. `period: 60`, written as if + * it meant 60 seconds, is therefore read as a 60 ms window and makes the + * limit a thousand times LOOSER: twelve hits per minute becomes twelve hits + * per 60 ms. The relay honours it without complaint, and nothing can tell the + * two apart from the number alone, which is why the unit is documented where + * the value is set. The validator rejects anything that is not a whole count + * of milliseconds, at least one. + */ period: number rate: number } @@ -224,10 +236,31 @@ export interface FeeSchedules { publication: FeeSchedule[] } +export interface SubscriptionPlanLimits { + /** Replaces limits.event.rateLimits for subscribers on this plan. */ + rateLimits?: EventRateLimit[] + /** Replaces limits.event.retention.maxDays for subscribers on this plan. */ + retentionMaxDays?: number +} + +export interface SubscriptionPlan { + /** Unique, and never `grandfathered`, which is reserved. */ + id: string + description?: string + /** Price of one period in msats. Unique across plans, since the amount paid selects the plan. */ + amount: bigint + intervalSeconds: number + /** How long access lasts after a period ends without a renewal. */ + graceSeconds: number + limits?: SubscriptionPlanLimits +} + export interface Payments { enabled: boolean processor: keyof PaymentsProcessors feeSchedules: FeeSchedules + /** Recurring membership plans. Empty keeps the one-time admission fee. */ + subscriptionPlans?: SubscriptionPlan[] /** Fallback when the processor reports no expiry. A reported one always wins. */ invoiceExpirySeconds?: number } @@ -279,18 +312,11 @@ export interface Remote { secret: Secret } -export interface Mirror { - address: string - filters?: SubscriptionFilter[] - secret?: Secret - limits?: { - event?: EventLimits - } - skipAdmissionCheck?: boolean -} - +/** + * @deprecated The static mirroring worker was removed. Setting `mirroring.static` only logs a warning at start-up. + */ export interface Mirroring { - static?: Mirror[] + static?: unknown[] } export interface DvmWorker { @@ -314,6 +340,24 @@ export interface Nip45Settings { enabled?: boolean } +export interface Nip77Settings { + /** + * NIP-77 negentropy sync. Defaults to false. Even when true it only answers + * if `eventStore.backend` is postgres: other backends (e.g. strfry) bring + * their own negentropy implementation. + */ + enabled?: boolean + /** + * Most events one session may hold in memory; larger queries are rejected + * with `NEG-ERR blocked:`. Defaults to 100000. + */ + maxEventsPerSession?: number + /** Concurrent negentropy sessions allowed on one connection. Defaults to 3. */ + maxSessionsPerConnection?: number + /** A session with no `NEG-MSG` for this long is closed with `NEG-ERR closed:`. Defaults to 60000. */ + sessionIdleTimeoutMs?: number +} + export interface Nip50Settings { enabled?: boolean language?: string @@ -356,6 +400,8 @@ export interface Nip66Settings { * Optional NIP-52 geohash describing this monitor's network vantage for kind 10166 events. */ geohash?: string + historyRetentionRuns?: number + historyRetentionHours?: number } export interface Nip05Settings { @@ -473,6 +519,90 @@ export interface Nip56Settings { hideActionableReports: boolean } +export interface PeeringScope { + /** + * Event kinds this feed covers, as kinds or `[min, max]` ranges. + */ + kinds: (EventKinds | EventKindsRange)[] + /** + * Inclusive lower bound of the feed's time range as a unix timestamp in + * seconds. Omit for no lower bound. + */ + since?: number + /** + * Inclusive upper bound of the feed's time range as a unix timestamp in + * seconds. Omit for a feed that stays current. + */ + until?: number + /** + * Rolling window in seconds: the feed covers events from the last + * `windowSeconds` seconds. Mutually exclusive with `since`/`until`. + */ + windowSeconds?: number +} + +export interface PeeringTerm { + /** Amount charged for one peering period, denominated in `unit`. */ + amount: number + /** Unit of `amount`, for example `msats`. */ + unit: string + /** Length of the peering period in seconds. */ + period: number +} + +export interface PeeringPeer { + /** + * The peer relay's own pubkey (hex or `npub1...`). Peers are identified by + * this key, never by URL alone. + */ + pubkey: Pubkey + /** + * WebSocket URL used when connecting to this peer. Inbound AUTH is matched + * by `pubkey`; the URL is where this relay reaches it outbound. + */ + relayUrl: string + /** Whether this configured peer is active. Defaults to true. */ + enabled?: boolean +} + +export interface PeeringSettings { + /** + * Whether this relay offers peering feeds. Advertised as `peering.enabled` + * in the NIP-11 document. Advertising terms does not accept peers. + */ + enabled: boolean + /** + * Contact URI (for example `mailto:` or `https:`) for peering requests. + * At least one of `contact` or `endpoint` is required when enabled. + */ + contact?: string + /** + * WebSocket endpoint peers should connect to. At least one of `contact` or + * `endpoint` is required when enabled. + */ + endpoint?: string + /** + * NIP-13 proof-of-work bits required on peering requests. Absent or 0 means + * none. Advertised as `peering.min_pow_difficulty`. + */ + minRequestPowDifficulty?: number + /** + * Feeds this relay offers. At least one scope is required when enabled. + */ + scopes?: PeeringScope[] + /** + * Terms for peering in the NIP-11 fee-object shape (`amount`, `unit`, + * `period`). An empty list means settlement-free peering. + */ + terms?: PeeringTerm[] + /** + * Configured peering peers, trusted by pubkey. A connection that proves one + * of these pubkeys with NIP-42 AUTH is recognized as a peer; any other + * pubkey receives no peer recognition, even with a valid signature. + */ + peers?: PeeringPeer[] +} + export interface Settings { info: Info admin?: AdminSettings @@ -491,5 +621,7 @@ export interface Settings { nip50?: Nip50Settings nip56?: Nip56Settings nip66?: Nip66Settings + nip77?: Nip77Settings + peering?: PeeringSettings wot?: WoTSettings } diff --git a/src/@types/user-subscription.ts b/src/@types/user-subscription.ts new file mode 100644 index 000000000..2b91bf8a2 --- /dev/null +++ b/src/@types/user-subscription.ts @@ -0,0 +1,36 @@ +import { Pubkey } from './base' + +export enum SubscriptionStatus { + ACTIVE = 'active', + RENEWAL_PENDING = 'renewal_pending', + PAST_DUE = 'past_due', + EXPIRED = 'expired', +} + +/** Plan id of the subscriptions created for users admitted before subscription plans existed. */ +export const GRANDFATHERED_PLAN_ID = 'grandfathered' + +export interface UserSubscription { + pubkey: Pubkey + planId: string + status: SubscriptionStatus + currentPeriodStart: Date + /** Null when the subscription never ends. */ + currentPeriodEnd: Date | null + graceEndsAt: Date | null + cancelAtPeriodEnd: boolean + createdAt: Date + updatedAt: Date +} + +export interface DBUserSubscription { + pubkey: Buffer + plan_id: string + status: SubscriptionStatus + current_period_start: Date + current_period_end: Date | null + grace_ends_at: Date | null + cancel_at_period_end: boolean + created_at: Date + updated_at: Date +} diff --git a/src/@types/utils.ts b/src/@types/utils.ts index 5e8674223..e6c0d07b7 100644 --- a/src/@types/utils.ts +++ b/src/@types/utils.ts @@ -3,6 +3,19 @@ export interface IRateLimiterOptions { rate: number } +/** + * The outcome of one limiter hit. + * + * `retryAfterMs` is an estimate of how long the caller must wait before its + * next attempt can be accepted, and it is absent when the strategy cannot say + * so. Absence means "limited, no estimate" — it must never be read as "retry + * immediately", and the caller decides what to send instead. + */ +export interface IRateLimiterResult { + limited: boolean + retryAfterMs?: number +} + export interface IRateLimiter { - hit(key: string, step: number, options: IRateLimiterOptions): Promise + hit(key: string, step: number, options: IRateLimiterOptions): Promise } diff --git a/src/adapters/redis-adapter.ts b/src/adapters/redis-adapter.ts index 01852ea47..d997a9101 100644 --- a/src/adapters/redis-adapter.ts +++ b/src/adapters/redis-adapter.ts @@ -84,6 +84,12 @@ export class RedisAdapter implements ICacheAdapter { return this.client.zRemRangeByScore(key, min, max) } + public async removeRangeByRankFromSortedSet(key: string, start: number, stop: number): Promise { + await this.connection + logger('remove rank %d..%d from sorted set %s', start, stop, key) + return this.client.zRemRangeByRank(key, start, stop) + } + public async getRangeFromSortedSet(key: string, min: number, max: number): Promise { await this.connection logger('get %d..%d range from sorted set %s', min, max, key) @@ -134,12 +140,36 @@ export class RedisAdapter implements ICacheAdapter { return this.client.sMembers(key) } + private isNoScriptError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + + const candidate = error as { code?: string; message?: string } + return candidate.code === 'NOSCRIPT' || (candidate.message?.includes('NOSCRIPT') ?? false) + } + public async eval(script: string, keys: string[], args: string[]): Promise { await this.connection - if (!this.scriptShas.has(script)) { - const sha = await this.client.scriptLoad(script) - this.scriptShas.set(script, sha) + + const runEvalSha = async (): Promise => { + if (!this.scriptShas.has(script)) { + const sha = await this.client.scriptLoad(script) + this.scriptShas.set(script, sha) + } + + return this.client.evalSha(this.scriptShas.get(script)!, { keys, arguments: args }) + } + + try { + return await runEvalSha() + } catch (error) { + if (!this.isNoScriptError(error)) { + throw error + } + + this.scriptShas.delete(script) + return runEvalSha() } - return await this.client.evalSha(this.scriptShas.get(script)!, { keys, arguments: args }) } } diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 8f107f3c2..3f64e5f55 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -8,11 +8,14 @@ import { ContextMetadata, Factory } from '../@types/base' import { createAuthChallengeMessage, createClosedMessage, + createNegErrMessage, createNoticeMessage, createOutgoingEventMessage, } from '../utils/messages' import { IAbortable, IMessageHandler } from '../@types/message-handlers' import { IncomingMessage, MessageType, OutgoingMessage } from '../@types/messages' +import { INegentropySessions } from '../@types/negentropy' +import { NegentropySessions } from '../utils/negentropy-sessions' import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters' import { SubscriptionFilter, SubscriptionId } from '../@types/subscription' import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants/adapter' @@ -24,6 +27,7 @@ import { Event } from '../@types/event' import { getRemoteAddress } from '../utils/http' import { createReadAuthorizationGuard } from '../utils/nip42' import { Nip42SessionManager } from '../utils/nip42-session' +import { isConfiguredPeeringPeer } from '../utils/peering-auth' import { IRateLimiter } from '../@types/utils' import { isEventMatchingFilter } from '../utils/event' import { isHidden } from '../utils/hidden-content-cache' @@ -32,6 +36,36 @@ import { Settings } from '../@types/settings' import { SocketAddress } from 'net' const logger = createLogger('web-socket-adapter') + +/** + * The NIP-01 type of a client frame, or `OTHER` when it is not one. + * + * The message limiter runs before validation — it has to, or a client could + * spend the parse budget first — so it reads a bounded prefix instead of + * decoding the frame. A frame that does not start with `["WORD"` shares the + * `OTHER` bucket; it is refused a moment later anyway. + */ +const THROTTLED_MESSAGE_TYPES = new Set([ + 'REQ', + 'EVENT', + 'COUNT', + 'CLOSE', + 'AUTH', + 'NEG-OPEN', + 'NEG-MSG', + 'NEG-CLOSE', +]) + +const messageType = (raw: Buffer): string => { + const prefix = raw.toString('utf8', 0, Math.min(raw.length, 32)) + const match = /^\s*\[\s*"([A-Za-z-]{1,8})"/.exec(prefix) + const type = match ? match[1].toUpperCase() : '' + + // A closed set, not whatever the frame claims. Bucketing by an arbitrary word + // would let a client rotate invented types (`["AAAA"]`, `["BBBB"]`, …) and + // keep every bucket empty; anything unrecognised shares one bucket. + return THROTTLED_MESSAGE_TYPES.has(type) ? type : 'OTHER' +} const debugHeartbeat = logger.extend('heartbeat') const abortableMessageHandlers: WeakMap = new WeakMap() @@ -42,6 +76,10 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private alive: boolean private subscriptions: Map private readonly session: Nip42SessionManager + /** Configured peering peer proven by AUTH on this socket, if any. */ + private peerPubkey: string | undefined + // NIP-77 + private readonly negentropySessions: NegentropySessions public constructor( private readonly client: WebSocket, @@ -54,6 +92,9 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter super() this.alive = true this.subscriptions = new Map() + this.negentropySessions = new NegentropySessions((subscriptionId) => + this.sendMessage(createNegErrMessage(subscriptionId, 'closed: negentropy session timed out')), + ) this.clientId = Buffer.from(this.request.headers['sec-websocket-key'] as string, 'base64').toString('hex') @@ -175,11 +216,21 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter this.sendMessage(createClosedMessage(subscriptionId, `error: ${reason}`)) }) + this.negentropySessions.getSessionIds().forEach((subscriptionId) => { + this.sendMessage(createNegErrMessage(subscriptionId, `closed: ${reason}`)) + }) + this.negentropySessions.closeAll() + if (this.client.readyState === WebSocket.OPEN) { this.client.close(1001, reason) } } + // NIP-77 + public getNegentropySessions(): INegentropySessions { + return this.negentropySessions + } + // NIP-42 public getChallenge(): string { return this.session.getChallenge() @@ -195,12 +246,36 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter return this.session.authenticate(pubkey, authEventId) } + public getPeerPubkey(): string | undefined { + if (!this.peerPubkey) { + return undefined + } + + // Peer status lives and dies with its AUTH session, and with the operator's + // current trust list: disabling or removing the peer revokes it on the next + // read even if the socket stays authenticated. + if ( + !this.session.getAuthenticatedPubkeys().has(this.peerPubkey) || + !isConfiguredPeeringPeer(this.settings(), this.peerPubkey) + ) { + this.peerPubkey = undefined + return undefined + } + + return this.peerPubkey + } + + public setPeerPubkey(pubkey: string): void { + const authenticated = this.session.getAuthenticatedPubkeys().has(pubkey) + this.peerPubkey = authenticated && isConfiguredPeeringPeer(this.settings(), pubkey) ? pubkey : undefined + } + private async onClientMessage(raw: Buffer) { this.alive = true let abortable = false let messageHandler: (IMessageHandler & IAbortable) | undefined = undefined try { - const retryAfterMs = await this.isRateLimited(this.clientAddress.address) + const retryAfterMs = await this.isRateLimited(this.clientAddress.address, messageType(raw)) if (retryAfterMs) { this.sendMessage(this.createRateLimitedMessage(raw, retryAfterMs)) return @@ -258,10 +333,15 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter } /** - * Resolves to false when allowed; otherwise to the tripped window's period in ms, - * sent as the NIP-01 `rate-limited` backoff. + * Resolves to false when allowed; otherwise to the backoff in ms, sent as the + * NIP-01 `rate-limited` parameter. + * + * The bucket carries the message type: a client told to back off should back + * off *the message that tripped the limit*. One shared bucket refused a REQ + * because its EVENTs had filled it, which is the wrong message to punish and + * the wrong one to throttle. */ - private async isRateLimited(client: string): Promise { + private async isRateLimited(client: string, type: string): Promise { const { rateLimits, ipWhitelist = [] } = this.settings().limits?.message ?? {} if (!Array.isArray(rateLimits) || !rateLimits.length || ipWhitelist.includes(client)) { @@ -270,15 +350,16 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter const rateLimiter = this.rateLimiter() - const hit = (period: number, rate: number) => rateLimiter.hit(`${client}:message:${period}`, 1, { period, rate }) + const hit = (period: number, rate: number) => + rateLimiter.hit(`${client}:message:${type}:${period}`, 1, { period, rate }) for (const { rate, period } of rateLimits) { - const isRateLimited = await hit(period, rate) + const result = await hit(period, rate) - if (isRateLimited) { - logger('rate limited %s: %d messages / %d ms exceeded', client, rate, period) + if (result.limited) { + logger('rate limited %s (%s): %d messages / %d ms exceeded', client, type, rate, period) - return Math.max(1, Math.ceil(period)) + return result.retryAfterMs ?? Math.max(1, Math.ceil(period)) } } @@ -349,7 +430,9 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter recordWebsocketConnectionClosed() this.alive = false this.subscriptions.clear() + this.negentropySessions.closeAll() this.session.clear() + this.peerPubkey = undefined const handlers = abortableMessageHandlers.get(this.client) if (Array.isArray(handlers) && handlers.length) { diff --git a/src/admin/password-admin-auth-provider.ts b/src/admin/password-admin-auth-provider.ts index 9111934ae..9ac041546 100644 --- a/src/admin/password-admin-auth-provider.ts +++ b/src/admin/password-admin-auth-provider.ts @@ -15,8 +15,13 @@ import { } from '../utils/admin-session' import { validateSchema } from '../utils/validation' +export type AdminAuthAuditHook = (event: 'login' | 'logout', request: Request) => void | Promise + export class PasswordAdminAuthProvider implements IAdminAuthProvider { - public constructor(private readonly settings: () => Settings) {} + public constructor( + private readonly settings: () => Settings, + private readonly auditHook?: AdminAuthAuditHook, + ) {} public async handleLogin(request: Request, response: Response): Promise { const validation = validateSchema(adminLoginBodySchema)(request.body) @@ -37,6 +42,8 @@ export class PasswordAdminAuthProvider implements IAdminAuthProvider { try { const token = createAdminSessionToken(expiresAt) + void this.auditHook?.('login', request) + response .status(200) .setHeader('content-type', 'application/json') @@ -49,6 +56,7 @@ export class PasswordAdminAuthProvider implements IAdminAuthProvider { public handleLogout(request: Request, response: Response): void { const currentSettings = this.settings() + void this.auditHook?.('logout', request) response .status(200) diff --git a/src/app/app.ts b/src/app/app.ts index b3e505eb4..c11cb7939 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,5 +1,6 @@ import { Cluster, Worker } from 'cluster' import { cpus, hostname } from 'os' +import { levels } from 'pino' import { path, pathEq } from 'ramda' import { FSWatcher } from 'fs' @@ -11,6 +12,7 @@ import packageJson from '../../package.json' import { Serializable } from 'child_process' import { Settings } from '../@types/settings' import { SettingsStatic } from '../utils/settings' +import { BANNER_WIDTH, centre, isStructuredLogFormat, RELAY_BANNER_LINES } from '../utils/banner' import { shutdownMetricsTelemetry } from '../telemetry/metrics' import { OperatorNotificationEventType } from '../@types/operator-notifications' import { RedisRelayBroadcastFanout } from '../relay-broadcast/redis-relay-broadcast-fanout' @@ -53,30 +55,32 @@ export class App implements IRunnable { public run(): void { const settings = this.settings() this.watchers = SettingsStatic.watchSettings() - logger.info(` - ███▄ █ ▒█████ ██████ ▄▄▄█████▓ ██▀███ ▓█████ ▄▄▄ ███▄ ▄███▓ - ██ ▀█ █ ▒██▒ ██▒▒██ ▒ ▓ ██▒ ▓▒▓██ ▒ ██▒▓█ ▀▒████▄ ▓██▒▀█▀ ██▒ -▓██ ▀█ ██▒▒██░ ██▒░ ▓██▄ ▒ ▓██░ ▒░▓██ ░▄█ ▒▒███ ▒██ ▀█▄ ▓██ ▓██░ -▓██▒ ▐▌██▒▒██ ██░ ▒ ██▒░ ▓██▓ ░ ▒██▀▀█▄ ▒▓█ ▄░██▄▄▄▄██ ▒██ ▒██ -▒██░ ▓██░░ ████▓▒░▒██████▒▒ ▒██▒ ░ ░██▓ ▒██▒░▒████▒▓█ ▓██▒▒██▒ ░██▒ -░ ▒░ ▒ ▒ ░ ▒░▒░▒░ ▒ ▒▓▒ ▒ ░ ▒ ░░ ░ ▒▓ ░▒▓░░░ ▒░ ░▒▒ ▓▒█░░ ▒░ ░ ░ -░ ░░ ░ ▒░ ░ ▒ ▒░ ░ ░▒ ░ ░ ░ ░▒ ░ ▒░ ░ ░ ░ ▒ ▒▒ ░░ ░ ░ - ░ ░ ░ ░ ░ ░ ▒ ░ ░ ░ ░ ░░ ░ ░ ░ ▒ ░ ░ - ░ ░ ░ ░ ░ ░ ░ ░ ░ ░`) - const width = 74 + // Plain text on stdout, not through the logger: in JSON output every line of + // the logo arrived as its own record with the newlines escaped, which is not + // a logo. A pipeline that insists every line parse sets LOG_FORMAT=json and + // gets one record instead of artwork (see below). + const structuredLogs = isStructuredLogFormat(this.process.env.LOG_FORMAT) + const logLine = (text: string) => { + if (structuredLogs) { + return + } + + this.process.stdout.write(`${text}\n`) + } + + logLine(RELAY_BANNER_LINES.join('\n')) + + const width = BANNER_WIDTH const torHiddenServicePort = process.env.HIDDEN_SERVICE_PORT ? Number(process.env.HIDDEN_SERVICE_PORT) : 80 const port = process.env.RELAY_PORT ? Number(process.env.RELAY_PORT) : 8008 - const logCentered = (input: string, width: number) => { - const start = Math.max(0, (width - input.length) >> 1) - logger.info(' '.repeat(start), input) - } - logCentered(`v${packageJson.version}`, width) - logCentered(`NIPs implemented: ${packageJson.supportedNips}`, width) + const logCentered = (input: string) => logLine(centre(input, width)) + logCentered(`v${packageJson.version}`) + logCentered(`NIPs implemented: ${packageJson.supportedNips}`) const paymentsEnabled = pathEq(['payments', 'enabled'], true, settings) - logCentered(`Pay-to-relay ${paymentsEnabled ? 'enabled' : 'disabled'}`, width) + logCentered(`Pay-to-relay ${paymentsEnabled ? 'enabled' : 'disabled'}`) if (paymentsEnabled) { - logCentered(`Payments provider: ${path(['payments', 'processor'], settings)}`, width) + logCentered(`Payments provider: ${path(['payments', 'processor'], settings)}`) } if ( @@ -98,12 +102,35 @@ export class App implements IRunnable { this.process.exit(1) return } - logCentered(`Event store: ${eventStoreBackend}`, width) + logCentered(`Event store: ${eventStoreBackend}`) const workerCount = process.env.WORKER_COUNT ? Number(process.env.WORKER_COUNT) : this.settings().workers?.count || cpus().length + // The structured form of the banner: the same facts as fields, written once + // and before any worker forks, so it opens the stream. The keys are pino's + // own so a pipeline reads this line like every other one. + if (structuredLogs) { + const dvmWorkerCount = Array.isArray(settings?.dvm?.workers) ? settings.dvm.workers.length : 0 + this.process.stdout.write(`${JSON.stringify({ + level: levels.values.info, + time: Date.now(), + pid: process.pid, + hostname: hostname(), + msg: 'relay starting', + version: packageJson.version, + supportedNips: packageJson.supportedNips, + payments: paymentsEnabled ? 'enabled' : 'disabled', + paymentsProvider: paymentsEnabled ? path(['payments', 'processor'], settings) : undefined, + eventStore: eventStoreBackend, + clientWorkers: workerCount, + maintenanceWorkers: 1, + relayMonitor: settings.nip66?.enabled === true, + dvmWorkers: dvmWorkerCount, + })}\n`) + } + const createWorker = (env: Record) => { const workerEnv = { ...env, [EVENT_STORE_BACKEND_ENV]: eventStoreBackend } const worker = this.cluster.fork(workerEnv) @@ -117,30 +144,25 @@ export class App implements IRunnable { WORKER_INDEX: i.toString(), }) } - logCentered(`${workerCount} client workers started`, width) + logCentered(`${workerCount} client workers started`) createWorker({ WORKER_TYPE: 'maintenance', }) - logCentered('1 maintenance worker started', width) - const mirrors = settings?.mirroring?.static - - if (Array.isArray(mirrors) && mirrors.length) { - for (let i = 0; i < mirrors.length; i++) { - createWorker({ - WORKER_TYPE: 'static-mirroring', - MIRROR_INDEX: i.toString(), - }) - } - logCentered(`${mirrors.length} static-mirroring worker started`, width) + logCentered('1 maintenance worker started') + if (Array.isArray(settings?.mirroring?.static) && settings.mirroring.static.length) { + logger.warn( + 'mirroring.static is no longer supported and is ignored; sync with another relay over NIP-77 (negentropy) instead', + ) + logCentered('mirroring.static is no longer supported (ignored)') } if (settings.nip66?.enabled) { createWorker({ WORKER_TYPE: 'relay-monitor', }) - logCentered('1 relay-monitor worker started', width) + logCentered('1 relay-monitor worker started') } const dvmWorkers = settings?.dvm?.workers @@ -152,7 +174,7 @@ export class App implements IRunnable { DVM_WORKER_INDEX: i.toString(), }) } - logCentered(`${dvmWorkers.length} dvm-orchestrator worker started`, width) + logCentered(`${dvmWorkers.length} dvm-orchestrator worker started`) } logger('settings: %O', settings) @@ -164,7 +186,7 @@ export class App implements IRunnable { .start((message) => this.onRelayBroadcastFromPeer(message)) .then(() => { setRelayBroadcastFanoutReady(true) - logCentered('Relay broadcast fan-out enabled (Redis stream)', width) + logCentered('Relay broadcast fan-out enabled (Redis stream)') }) .catch((error) => { setRelayBroadcastFanoutReady(false) @@ -182,10 +204,10 @@ export class App implements IRunnable { const host = `${hostname()}:${port}` addOnion(torHiddenServicePort, host).then( (value) => { - logCentered(`Tor hidden service: ${value}:${torHiddenServicePort}`, width) + logCentered(`Tor hidden service: ${value}:${torHiddenServicePort}`) }, () => { - logCentered('Tor hidden service: disabled', width) + logCentered('Tor hidden service: disabled') }, ) } diff --git a/src/app/maintenance-worker.ts b/src/app/maintenance-worker.ts index acd9114b1..b3d9e1f42 100644 --- a/src/app/maintenance-worker.ts +++ b/src/app/maintenance-worker.ts @@ -1,30 +1,36 @@ -import { - DEFAULT_NIP05_MAX_CONSECUTIVE_FAILURES, - DEFAULT_NIP05_VERIFY_UPDATE_FREQUENCY_MS, - Nip05VerificationOutcome, - verifyNip05Identifier, -} from '../utils/nip05' -import { IMaintenanceService, INotificationOutboxService, IPaymentsService } from '../@types/services' +import { FSWatcher } from 'fs' import { mergeDeepLeft, path, pipe } from 'ramda' +import { ICacheAdapter } from '../@types/adapters' import { IRunnable } from '../@types/base' - -import { createLogger } from '../factories/logger-factory' -import { PENDING_INVOICE_PAGE_SIZE } from '../services/payments-service' -import { delayMs } from '../utils/misc' +import { InvoiceStatus } from '../@types/invoice' +import { Nip05Verification } from '../@types/nip05' import { OperatorNotificationEventType } from '../@types/operator-notifications' import { INip05VerificationRepository, INotificationDeliveryLogRepository, INotificationOutboxRepository, } from '../@types/repositories' -import { InvoiceStatus } from '../@types/invoice' -import { isExpiredInvoice } from '../utils/invoice' -import { Nip05Verification } from '../@types/nip05' -import { FSWatcher } from 'fs' - +import { + IMaintenanceService, + INotificationOutboxService, + IPaymentsService, + ISubscriptionService, +} from '../@types/services' import { Settings } from '../@types/settings' -import { SettingsStatic } from '../utils/settings' +import { createLogger } from '../factories/logger-factory' +import { PENDING_INVOICE_PAGE_SIZE } from '../services/payments-service' import { shutdownMetricsTelemetry } from '../telemetry/metrics' +import { sanitizeAdminOpsError } from '../utils/admin-ops-sanitize' +import { publishAdminOpsWorkerHeartbeatSafely } from '../utils/admin-ops-store' +import { isExpiredInvoice } from '../utils/invoice' +import { delayMs } from '../utils/misc' +import { + DEFAULT_NIP05_MAX_CONSECUTIVE_FAILURES, + DEFAULT_NIP05_VERIFY_UPDATE_FREQUENCY_MS, + Nip05VerificationOutcome, + verifyNip05Identifier, +} from '../utils/nip05' +import { SettingsStatic } from '../utils/settings' const UPDATE_INVOICE_INTERVAL = 60000 const NIP05_REVERIFICATION_BATCH_SIZE = 50 @@ -98,6 +104,8 @@ export class MaintenanceWorker implements IRunnable { private readonly notificationOutboxService: INotificationOutboxService, private readonly notificationDeliveryLogRepository: INotificationDeliveryLogRepository, private readonly notificationOutboxRepository: INotificationOutboxRepository, + private readonly opsCache?: ICacheAdapter, + private readonly subscriptionService?: ISubscriptionService, ) { this.process .on('SIGINT', this.onExit.bind(this)) @@ -133,6 +141,10 @@ export class MaintenanceWorker implements IRunnable { try { await this.onSchedule() } catch (error) { + await this.publishOpsHeartbeat({ + lastRunFinishedAt: new Date().toISOString(), + lastError: sanitizeAdminOpsError(error), + }) this.onError(error as Error) } finally { this.isRunning = false @@ -140,7 +152,26 @@ export class MaintenanceWorker implements IRunnable { }, UPDATE_INVOICE_INTERVAL) } + private async publishOpsHeartbeat(partial: { + lastRunStartedAt?: string + lastRunFinishedAt?: string + lastError?: string + }): Promise { + if (!this.opsCache) { + return + } + + await publishAdminOpsWorkerHeartbeatSafely(this.opsCache, { + type: 'maintenance', + updatedAt: new Date().toISOString(), + ...partial, + }) + } + private async onSchedule(): Promise { + const lastRunStartedAt = new Date().toISOString() + await this.publishOpsHeartbeat({ lastRunStartedAt }) + const currentSettings = this.settings() const clearOldEventsPromise = this.clearOldEventsSafely() @@ -149,6 +180,10 @@ export class MaintenanceWorker implements IRunnable { if (!path(['payments', 'enabled'], currentSettings)) { await clearOldEventsPromise + await this.publishOpsHeartbeat({ + lastRunStartedAt, + lastRunFinishedAt: new Date().toISOString(), + }) return } @@ -222,7 +257,24 @@ export class MaintenanceWorker implements IRunnable { logger('updated %d of %d invoices successfully', successful, invoices.length) } + await this.processSubscriptions() + await clearOldEventsPromise + await this.publishOpsHeartbeat({ + lastRunStartedAt, + lastRunFinishedAt: new Date().toISOString(), + }) + } + + private async processSubscriptions(): Promise { + try { + const changed = await this.subscriptionService?.processLifecycle() + if (changed) { + logger('moved %d subscription(s) through their lifecycle', changed) + } + } catch (error) { + logger.error('Unable to process subscriptions', error) + } } private async processNotificationOutbox(): Promise { diff --git a/src/app/relay-monitor-worker.ts b/src/app/relay-monitor-worker.ts index d7caab6e3..57a178ea5 100644 --- a/src/app/relay-monitor-worker.ts +++ b/src/app/relay-monitor-worker.ts @@ -1,16 +1,25 @@ +import { ICacheAdapter } from '../@types/adapters' +import { AdminOpsWorkerHeartbeat } from '../@types/admin-ops' import { IRunnable } from '../@types/base' import { IRelayProbeSnapshotStore, RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot' import { Settings } from '../@types/settings' import { createLogger } from '../factories/logger-factory' import { INip66EventPublisher } from '../services/nip66-event-publisher' +import { recordProbeRunMetrics } from '../telemetry/ops-metrics' import { shutdownMetricsTelemetry } from '../telemetry/metrics' -import { filterValidProbeTargets, resolveProbeTargets } from '../utils/relay-probe-targets' -import { deriveRelayProbeRunStatus, serializeProbeResults } from '../utils/relay-probe-snapshot' -import { resolvePublicProbeTargetKeys } from '../utils/relay-probe-targets' -import { getEffectiveProbeIntervalSeconds, getProbeIntervalMs } from '../utils/nip66-schedule' +import { notifyProbeRunTransition } from '../utils/ops-trust-notifications' +import { ADMIN_OPS_HEARTBEAT_KEEPALIVE_MS, publishAdminOpsWorkerHeartbeatSafely } from '../utils/admin-ops-store' import { getMonitorPrivateKey } from '../utils/monitor-identity' +import { getEffectiveProbeIntervalSeconds, getNip66HistoryRetention, getProbeIntervalMs } from '../utils/nip66-schedule' import { runProbe } from '../utils/relay-probe' import { ProbeOptions, ProbeResult } from '../utils/relay-probe/types' +import { RelayProbeHistoryStore } from '../utils/relay-probe-history' +import { deriveRelayProbeRunStatus, serializeProbeResults } from '../utils/relay-probe-snapshot' +import { + filterValidProbeTargets, + resolveProbeTargets, + resolvePublicProbeTargetKeys, +} from '../utils/relay-probe-targets' const logger = createLogger('relay-monitor-worker') @@ -29,9 +38,16 @@ export const buildProbeOptions = (settings: Settings): ProbeOptions => { export { getProbeIntervalMs } from '../utils/nip66-schedule' +type RelayMonitorOpsHeartbeatFields = Pick< + AdminOpsWorkerHeartbeat, + 'lastRunStartedAt' | 'lastRunFinishedAt' | 'lastError' +> + export class RelayMonitorWorker implements IRunnable { private interval: NodeJS.Timeout | undefined + private opsKeepaliveInterval: NodeJS.Timeout | undefined private isRunning = false + private lastOpsWorkerHeartbeat: RelayMonitorOpsHeartbeatFields = {} public constructor( private readonly process: NodeJS.Process, @@ -39,6 +55,8 @@ export class RelayMonitorWorker implements IRunnable { private readonly snapshotStore: IRelayProbeSnapshotStore, private readonly probeRunner: RunProbeFn = runProbe, private readonly eventPublisher?: INip66EventPublisher, + private readonly historyStore?: RelayProbeHistoryStore, + private readonly opsCache?: ICacheAdapter, ) { this.process .on('SIGINT', this.onExit.bind(this)) @@ -64,6 +82,24 @@ export class RelayMonitorWorker implements IRunnable { this.interval = setInterval(() => { void this.runScheduledProbes() }, intervalMs) + + if (this.opsCache) { + this.opsKeepaliveInterval = setInterval(() => { + this.publishOpsKeepalive() + }, ADMIN_OPS_HEARTBEAT_KEEPALIVE_MS) + } + } + + private publishOpsKeepalive(): void { + if (!this.opsCache || this.lastOpsWorkerHeartbeat.lastRunStartedAt === undefined) { + return + } + + void publishAdminOpsWorkerHeartbeatSafely(this.opsCache, { + type: 'relay-monitor', + updatedAt: new Date().toISOString(), + ...this.lastOpsWorkerHeartbeat, + }) } private async runScheduledProbes(): Promise { @@ -83,7 +119,31 @@ export class RelayMonitorWorker implements IRunnable { } } + private async publishOpsHeartbeat(partial: RelayMonitorOpsHeartbeatFields): Promise { + if (!this.opsCache) { + return + } + + this.lastOpsWorkerHeartbeat = { + ...this.lastOpsWorkerHeartbeat, + ...partial, + } + if ('lastError' in partial && partial.lastError === undefined) { + delete this.lastOpsWorkerHeartbeat.lastError + } + + await publishAdminOpsWorkerHeartbeatSafely(this.opsCache, { + type: 'relay-monitor', + updatedAt: new Date().toISOString(), + ...this.lastOpsWorkerHeartbeat, + }) + } + private async onSchedule(): Promise { + const probeRunStartedMs = Date.now() + const lastRunStartedAt = new Date().toISOString() + await this.publishOpsHeartbeat({ lastRunStartedAt }) + const currentSettings = this.settings() if (!currentSettings.nip66?.enabled) { @@ -101,6 +161,11 @@ export class RelayMonitorWorker implements IRunnable { if (valid.length === 0) { logger.warn('no valid probe targets configured; skipping probe run') + await this.publishOpsHeartbeat({ + lastRunStartedAt, + lastRunFinishedAt: new Date().toISOString(), + lastError: 'No valid probe targets configured', + }) return } @@ -117,6 +182,11 @@ export class RelayMonitorWorker implements IRunnable { if (results.length === 0) { logger.warn('probe run produced no results') + await this.publishOpsHeartbeat({ + lastRunStartedAt, + lastRunFinishedAt: new Date().toISOString(), + lastError: 'Probe run produced no results', + }) return } @@ -136,6 +206,15 @@ export class RelayMonitorWorker implements IRunnable { const expirySeconds = getEffectiveProbeIntervalSeconds(currentSettings) * 2 await this.snapshotStore.saveLatest(snapshot, expirySeconds) + recordProbeRunMetrics(snapshot.status, Date.now() - probeRunStartedMs) + await notifyProbeRunTransition(this.opsCache, snapshot) + if (this.historyStore) { + try { + await this.historyStore.append(snapshot, getNip66HistoryRetention(currentSettings)) + } catch (error) { + logger.error('failed to save probe history: %o', error) + } + } logger('saved probe snapshot for %d target(s) with status %s', valid.length, snapshot.status) if (this.eventPublisher) { @@ -145,6 +224,12 @@ export class RelayMonitorWorker implements IRunnable { logger.error('failed to publish NIP-66 events: %o', error) } } + + await this.publishOpsHeartbeat({ + lastRunStartedAt, + lastRunFinishedAt: new Date().toISOString(), + lastError: undefined, + }) } private onError(error: Error) { @@ -164,6 +249,7 @@ export class RelayMonitorWorker implements IRunnable { public close(callback?: () => void) { logger('closing') clearInterval(this.interval) + clearInterval(this.opsKeepaliveInterval) if (typeof callback === 'function') { callback() } diff --git a/src/app/static-mirroring-worker.ts b/src/app/static-mirroring-worker.ts deleted file mode 100644 index d725e404d..000000000 --- a/src/app/static-mirroring-worker.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { anyPass, map, mergeDeepRight, path } from 'ramda' -import { RawData, WebSocket } from 'ws' -import cluster from 'cluster' -import { randomUUID } from 'crypto' - -import { createRelayedEventMessage, createSubscriptionMessage } from '../utils/messages' -import { EventLimits, FeeSchedule, Mirror, Settings } from '../@types/settings' -import { - getEventExpiration, - getEventProofOfWork, - getPubkeyProofOfWork, - getPublicKey, - getRelayPrivateKey, - isDirectMessageEvent, - isEventIdValid, - isEventKindOrRangeMatch, - isEventMatchingFilter, - isEventSignatureValid, - isExpiredEvent, - isFileMessageEvent, - isSealEvent, -} from '../utils/event' -import { IEventRepository, IUserRepository } from '../@types/repositories' -import { createLogger } from '../factories/logger-factory' -import { Event } from '../@types/event' -import { EventExpirationTimeMetadataKey } from '../constants/base' -import { IRunnable } from '../@types/base' -import { OutgoingEventMessage } from '../@types/messages' -import { RelayedEvent } from '../@types/event' -import { WebSocketServerAdapterEvent } from '../constants/adapter' -import { shutdownMetricsTelemetry } from '../telemetry/metrics' - -const logger = createLogger('static-mirror-worker') - -export class StaticMirroringWorker implements IRunnable { - private client: WebSocket | undefined - private config: Mirror - - public constructor( - private readonly eventRepository: IEventRepository, - private readonly userRepository: IUserRepository, - private readonly process: NodeJS.Process, - private readonly settings: () => Settings, - ) { - this.process - .on('message', this.onMessage.bind(this)) - .on('SIGINT', this.onExit.bind(this)) - .on('SIGHUP', this.onExit.bind(this)) - .on('SIGTERM', this.onExit.bind(this)) - .on('uncaughtException', this.onError.bind(this)) - .on('unhandledRejection', this.onError.bind(this)) - } - - public run(): void { - const currentSettings = this.settings() - - logger.info('mirroring', currentSettings.mirroring) - - this.config = path(['mirroring', 'static', process.env.MIRROR_INDEX], currentSettings) as Mirror - - let since = Math.floor(Date.now() / 1000) - 60 * 10 - - const createMirror = (config: Mirror) => { - const subscriptionId = `mirror-${randomUUID()}` - - logger('connecting to %s', config.address) - - return new WebSocket(config.address, { timeout: 5000 }) - .on('open', function () { - logger('connected to %s', config.address) - - if (Array.isArray(config.filters) && config.filters?.length) { - const filters = config.filters.map((filter) => ({ ...filter, since })) - - logger('subscribing with %s: %o', subscriptionId, filters) - - this.send(JSON.stringify(createSubscriptionMessage(subscriptionId, filters))) - } - }) - .on('message', async (raw: RawData) => { - try { - const message = JSON.parse(raw.toString('utf8')) as OutgoingEventMessage - - if (!Array.isArray(message)) { - return - } - - if (message[0] !== 'EVENT' || message[1] !== subscriptionId) { - logger('%s >> local: %o', config.address, message) - return - } - - let event = message[2] - - if (!anyPass(map(isEventMatchingFilter, config.filters))(event)) { - return - } - - if (!(await isEventIdValid(event)) || !(await isEventSignatureValid(event))) { - return - } - - if (isExpiredEvent(event)) { - return - } - - const eventExpiration = getEventExpiration(event) - if (eventExpiration) { - event = { - ...event, - [EventExpirationTimeMetadataKey]: eventExpiration, - } as any - } - - if (!this.canAcceptEvent(event)) { - return - } - - if (!(await this.isUserAdmitted(event))) { - return - } - - // NIP-17: inner events (kind 13, 14, 15) must never be stored directly - if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event)) { - return - } - - since = Math.floor(Date.now() / 1000) - 30 - - logger('%s >> local: %s', config.address, event.id) - - const inserted = await this.eventRepository.create(event) - - if (inserted && cluster.isWorker && typeof process.send === 'function') { - process.send({ - eventName: WebSocketServerAdapterEvent.Broadcast, - event, - source: config.address, - }) - } - } catch (error) { - logger('unable to process message: %o', error) - } - }) - .on('close', (code, reason) => { - logger(`disconnected (${code}): ${reason.toString()}`) - - setTimeout(() => { - this.client.removeAllListeners() - this.client = createMirror(config) - }, 5000) - }) - .on('error', function (error) { - logger('connection error: %o', error) - }) - } - - this.client = createMirror(this.config) - } - - private getRelayPublicKey(): string { - const relayPrivkey = getRelayPrivateKey(this.settings().info.relay_url) - return getPublicKey(relayPrivkey) - } - - private canAcceptEvent(event: Event): boolean { - if (this.getRelayPublicKey() === event.pubkey) { - logger(`event ${event.id} not accepted: pubkey is relay pubkey`) - return false - } - - const now = Math.floor(Date.now() / 1000) - - const eventLimits = this.settings().limits?.event ?? {} - - const eventLimitOverrides = this.config.limits?.event ?? {} - - const limits = mergeDeepRight(eventLimits, eventLimitOverrides) as EventLimits - - if (Array.isArray(limits.content)) { - for (const limit of limits.content) { - if ( - typeof limit.maxLength !== 'undefined' && - limit.maxLength > 0 && - event.content.length > limit.maxLength && - (!Array.isArray(limit.kinds) || limit.kinds.some(isEventKindOrRangeMatch(event))) - ) { - logger(`event ${event.id} not accepted: content is longer than ${limit.maxLength} bytes`) - return false - } - } - } else if ( - typeof limits.content?.maxLength !== 'undefined' && - limits.content?.maxLength > 0 && - event.content.length > limits.content.maxLength && - (!Array.isArray(limits.content.kinds) || limits.content.kinds.some(isEventKindOrRangeMatch(event))) - ) { - logger(`event ${event.id} not accepted: content is longer than ${limits.content.maxLength} bytes`) - return false - } - - if ( - typeof limits.createdAt?.maxPositiveDelta !== 'undefined' && - limits.createdAt.maxPositiveDelta > 0 && - event.created_at > now + limits.createdAt.maxPositiveDelta - ) { - logger( - `event ${event.id} not accepted: created_at is more than ${limits.createdAt.maxPositiveDelta} seconds in the future`, - ) - return false - } - - if ( - typeof limits.createdAt?.maxNegativeDelta !== 'undefined' && - limits.createdAt.maxNegativeDelta > 0 && - event.created_at < now - limits.createdAt.maxNegativeDelta - ) { - logger( - `event ${event.id} not accepted: created_at is more than ${limits.createdAt.maxNegativeDelta} seconds in the past`, - ) - return false - } - - if (typeof limits.eventId?.minLeadingZeroBits !== 'undefined' && limits.eventId.minLeadingZeroBits > 0) { - const pow = getEventProofOfWork(event.id) - if (pow < limits.eventId.minLeadingZeroBits) { - logger(`event ${event.id} not accepted: pow difficulty ${pow}<${limits.eventId.minLeadingZeroBits}`) - return false - } - } - - if (typeof limits.pubkey?.minLeadingZeroBits !== 'undefined' && limits.pubkey.minLeadingZeroBits > 0) { - const pow = getPubkeyProofOfWork(event.pubkey) - if (pow < limits.pubkey.minLeadingZeroBits) { - logger(`event ${event.id} not accepted: pow pubkey difficulty ${pow}<${limits.pubkey.minLeadingZeroBits}`) - return false - } - } - - if ( - typeof limits.pubkey?.whitelist !== 'undefined' && - limits.pubkey.whitelist.length > 0 && - !limits.pubkey.whitelist.includes(event.pubkey) - ) { - logger(`event ${event.id} not accepted: pubkey not allowed: ${event.pubkey}`) - return false - } - - if ( - typeof limits.pubkey?.blacklist !== 'undefined' && - limits.pubkey.blacklist.length > 0 && - limits.pubkey.blacklist.includes(event.pubkey) - ) { - logger(`event ${event.id} not accepted: pubkey not allowed: ${event.pubkey}`) - return false - } - - if ( - typeof limits.kind?.whitelist !== 'undefined' && - limits.kind.whitelist.length > 0 && - !limits.kind.whitelist.some(isEventKindOrRangeMatch(event)) - ) { - logger(`blocked: event kind ${event.kind} not allowed`) - return false - } - - if ( - typeof limits.kind?.blacklist !== 'undefined' && - limits.kind.blacklist.length > 0 && - limits.kind.blacklist.some(isEventKindOrRangeMatch(event)) - ) { - logger(`blocked: event kind ${event.kind} not allowed`) - return false - } - - return true - } - - protected async isUserAdmitted(event: Event): Promise { - const currentSettings = this.settings() - - if (this.config.skipAdmissionCheck === true) { - return true - } - - if (currentSettings.payments?.enabled !== true) { - return true - } - - const isApplicableFee = (feeSchedule: FeeSchedule) => - feeSchedule.enabled && - !feeSchedule.whitelists?.pubkeys?.includes(event.pubkey) && - !feeSchedule.whitelists?.event_kinds?.some(isEventKindOrRangeMatch(event)) - - const feeSchedules = currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee) - - if (!Array.isArray(feeSchedules) || !feeSchedules.length) { - return true - } - - const user = await this.userRepository.findByPubkey(event.pubkey) - if (user?.isAdmitted !== true) { - logger(`user not admitted: ${event.pubkey}`) - return false - } - - const minBalance = currentSettings.limits?.event?.pubkey?.minBalance - if (minBalance && user.balance < minBalance) { - logger(`user not admitted: user balance ${user.balance} < ${minBalance}`) - return false - } - - return true - } - - private onMessage(message: { eventName: string; event: unknown; source: string }): void { - if ( - message.eventName !== WebSocketServerAdapterEvent.Broadcast || - message.source === this.config.address || - !this.client || - this.client.readyState !== WebSocket.OPEN - ) { - return - } - - const event = message.event as RelayedEvent - - const eventToRelay = createRelayedEventMessage(event, this.config.secret) - const outboundMessage = JSON.stringify(eventToRelay) - logger('%s >> %s: %s', message.source ?? 'local', this.config.address, outboundMessage) - this.client.send(outboundMessage) - } - - private onError(error: Error) { - logger('error: %o', error) - throw error - } - - private onExit() { - logger('exiting') - void shutdownMetricsTelemetry().finally(() => { - this.close(() => { - this.process.exit(0) - }) - }) - } - - public close(callback?: () => void) { - logger('closing') - if (this.client) { - this.client.terminate() - } - if (typeof callback === 'function') { - callback() - } - } -} diff --git a/src/cache/client.ts b/src/cache/client.ts index c00a4b4b7..2e4866f75 100644 --- a/src/cache/client.ts +++ b/src/cache/client.ts @@ -21,6 +21,14 @@ const redactRedisUrlCredentials = (url: string): string => { } } +export const isCacheConfigured = (): boolean => { + if (process.env.REDIS_URI?.trim()) { + return true + } + + return Boolean(process.env.REDIS_HOST?.trim() && process.env.REDIS_PORT?.trim()) +} + export const getCacheConfig = (): RedisClientOptions => { const password = process.env.REDIS_PASSWORD diff --git a/src/constants/caching.ts b/src/constants/caching.ts index 751614472..5bfd49105 100644 --- a/src/constants/caching.ts +++ b/src/constants/caching.ts @@ -5,3 +5,9 @@ export enum CacheAdmissionState { } export const admissionCacheKey = (pubkey: string): string => `${pubkey}:is-admitted` + +/** Holds `plan:` for a pubkey with an active subscription, or `none`. */ +export const subscriptionPlanCacheKey = (pubkey: string): string => `${pubkey}:subscription-plan` + +export const NO_SUBSCRIPTION_PLAN = 'none' +export const SUBSCRIPTION_PLAN_PREFIX = 'plan:' diff --git a/src/controllers/admin/get-admin-audit-log-controller.ts b/src/controllers/admin/get-admin-audit-log-controller.ts new file mode 100644 index 000000000..a08bdadea --- /dev/null +++ b/src/controllers/admin/get-admin-audit-log-controller.ts @@ -0,0 +1,19 @@ +import { Request, Response } from 'express' + +import { MAX_ADMIN_AUDIT_API_LIMIT } from '../../@types/admin-audit' +import { IController } from '../../@types/controllers' +import { AdminAuditStore } from '../../utils/admin-audit-store' + +export class GetAdminAuditLogController implements IController { + public constructor(private readonly auditStore: AdminAuditStore) {} + + public async handleRequest(request: Request, response: Response): Promise { + const limitCandidate = Number(request.query.limit ?? 100) + const limit = Number.isFinite(limitCandidate) + ? Math.min(Math.max(Math.floor(limitCandidate), 1), MAX_ADMIN_AUDIT_API_LIMIT) + : 100 + + const entries = await this.auditStore.list(limit) + response.status(200).setHeader('content-type', 'application/json').send({ entries }) + } +} diff --git a/src/controllers/admin/get-admin-diagnostics-export-controller.ts b/src/controllers/admin/get-admin-diagnostics-export-controller.ts new file mode 100644 index 000000000..94fae45c5 --- /dev/null +++ b/src/controllers/admin/get-admin-diagnostics-export-controller.ts @@ -0,0 +1,23 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { Settings } from '../../@types/settings' +import { buildAdminDiagnosticsExport } from '../../utils/admin-diagnostics-export' +import { loadMergedSettings } from '../../utils/settings-config' + +export class GetAdminDiagnosticsExportController implements IController { + public constructor(private readonly getSettings: () => Settings = loadMergedSettings) {} + + public async handleRequest(request: Request, response: Response): Promise { + const payload = await buildAdminDiagnosticsExport(this.getSettings) + const download = request.query.download === '1' || request.query.download === 'true' + + response.status(200).setHeader('content-type', 'application/json') + if (download) { + const stamp = payload.generatedAt.replace(/[:.]/g, '-') + response.setHeader('content-disposition', `attachment; filename="nostream-diagnostics-${stamp}.json"`) + } + + response.send(payload) + } +} diff --git a/src/controllers/admin/get-moderation-limits-controller.ts b/src/controllers/admin/get-moderation-limits-controller.ts new file mode 100644 index 000000000..27b99675d --- /dev/null +++ b/src/controllers/admin/get-moderation-limits-controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { readAdminModerationLimits } from '../../utils/admin-moderation' + +export class GetAdminModerationLimitsController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + response.status(200).setHeader('content-type', 'application/json').send({ + limits: readAdminModerationLimits(), + }) + } +} diff --git a/src/controllers/admin/get-moderation-stats-controller.ts b/src/controllers/admin/get-moderation-stats-controller.ts new file mode 100644 index 000000000..c4f7f6688 --- /dev/null +++ b/src/controllers/admin/get-moderation-stats-controller.ts @@ -0,0 +1,61 @@ +import { Request, Response } from 'express' + +import { RedisAdapter } from '../../adapters/redis-adapter' +import { IController } from '../../@types/controllers' +import { getCacheClient, isCacheConfigured } from '../../cache/client' +import { getReadReplicaDbClient } from '../../database/client' +import { createLogger } from '../../factories/logger-factory' +import { + parseModerationStatsLimit, + parseModerationStatsWindowHours, + queryTopClientIps, + queryTopPublishingPubkeys, +} from '../../utils/admin-moderation-stats' +import { listModerationRateLimitHits } from '../../utils/moderation-rate-limit-stats' + +const logger = createLogger('get-admin-moderation-stats-controller') + +const isStatsQueryTimeout = (error: unknown): boolean => + error instanceof Error && /query timeout|timed out/i.test(error.message) + +export class GetAdminModerationStatsController implements IController { + public async handleRequest(request: Request, response: Response): Promise { + const window = parseModerationStatsWindowHours(request.query.windowHours) + if (window.error || window.hours === undefined) { + response.status(400).setHeader('content-type', 'application/json').send({ error: window.error }) + return + } + + const limit = parseModerationStatsLimit(request.query.limit) + const sinceUnixSeconds = Math.floor(Date.now() / 1000) - window.hours * 3600 + + try { + const [topPubkeys, topIps] = await Promise.all([ + queryTopPublishingPubkeys(getReadReplicaDbClient(), sinceUnixSeconds, limit), + queryTopClientIps(getReadReplicaDbClient(), sinceUnixSeconds, limit), + ]) + + let rateLimitHits = { pubkeys: [] as { key: string; hits: number }[], ips: [] as { key: string; hits: number }[] } + if (isCacheConfigured()) { + const cache = new RedisAdapter(getCacheClient()) + rateLimitHits = await listModerationRateLimitHits(cache, limit, window.hours) + } + + response.status(200).setHeader('content-type', 'application/json').send({ + windowHours: window.hours, + since: sinceUnixSeconds, + topPublishingPubkeys: topPubkeys, + topClientIps: topIps, + rateLimitHits, + }) + } catch (error) { + if (isStatsQueryTimeout(error)) { + response.status(504).setHeader('content-type', 'application/json').send({ error: 'Moderation stats query timed out' }) + return + } + + logger.error('moderation stats query failed: %o', error) + response.status(500).setHeader('content-type', 'application/json').send({ error: 'Moderation stats query failed' }) + } + } +} diff --git a/src/controllers/admin/get-network-health-history-controller.ts b/src/controllers/admin/get-network-health-history-controller.ts new file mode 100644 index 000000000..c52e6f5a6 --- /dev/null +++ b/src/controllers/admin/get-network-health-history-controller.ts @@ -0,0 +1,49 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { Settings } from '../../@types/settings' +import { + buildNetworkHealthMismatchContext, + enrichSnapshotWithMismatches, +} from '../../utils/network-health-mismatches' +import { getNip66HistoryRetention } from '../../utils/nip66-schedule' +import { MAX_PROBE_HISTORY_API_LIMIT, RelayProbeHistoryStore } from '../../utils/relay-probe-history' +import { loadMergedSettings } from '../../utils/settings-config' + +export const DEFAULT_NETWORK_HEALTH_HISTORY_LIMIT = 24 + +export const parseNetworkHealthHistoryLimit = (raw: unknown): number | null => { + if (raw === undefined) { + return DEFAULT_NETWORK_HEALTH_HISTORY_LIMIT + } + + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed < 1) { + return null + } + + return Math.min(Math.floor(parsed), MAX_PROBE_HISTORY_API_LIMIT) +} + +export class GetAdminNetworkHealthHistoryController implements IController { + public constructor( + private readonly historyStore: RelayProbeHistoryStore, + private readonly getSettings: () => Settings = loadMergedSettings, + ) {} + + public async handleRequest(request: Request, response: Response): Promise { + const limit = parseNetworkHealthHistoryLimit(request.query.limit) + if (limit === null) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'limit must be a positive integer' }) + return + } + + const settings = this.getSettings() + const runs = await this.historyStore.list(limit, getNip66HistoryRetention(settings)) + const enriched = runs.map((snapshot) => + enrichSnapshotWithMismatches(snapshot, buildNetworkHealthMismatchContext(snapshot, settings)), + ) + + response.status(200).setHeader('content-type', 'application/json').send({ runs: enriched }) + } +} diff --git a/src/controllers/admin/get-ops-nip05-verifications-controller.ts b/src/controllers/admin/get-ops-nip05-verifications-controller.ts new file mode 100644 index 000000000..a273ff69c --- /dev/null +++ b/src/controllers/admin/get-ops-nip05-verifications-controller.ts @@ -0,0 +1,71 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { INip05VerificationRepository } from '../../@types/repositories' +import { Settings } from '../../@types/settings' +import { getNip05VerifyExpirationMs, toAdminNip05Row } from '../../utils/admin-ops-nip05' +import { loadMergedSettings } from '../../utils/settings-config' + +const DEFAULT_LIMIT = 50 +const MAX_LIMIT = 200 + +const parseBoolean = (value: unknown): boolean | undefined => { + if (value === undefined || value === '') { + return undefined + } + if (value === 'true' || value === true) { + return true + } + if (value === 'false' || value === false) { + return false + } + return undefined +} + +export class GetAdminOpsNip05VerificationsController implements IController { + public constructor( + private readonly nip05VerificationRepository: INip05VerificationRepository, + private readonly getSettings: () => Settings = loadMergedSettings, + ) {} + + public async handleRequest(request: Request, response: Response): Promise { + const settings = this.getSettings() + const verifyExpirationMs = getNip05VerifyExpirationMs(settings) + + const limitRaw = Number(request.query.limit ?? DEFAULT_LIMIT) + const offsetRaw = Number(request.query.offset ?? 0) + const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIMIT) : DEFAULT_LIMIT + const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0 + + const domain = typeof request.query.domain === 'string' ? request.query.domain.trim() : undefined + const verified = parseBoolean(request.query.verified) + const stale = parseBoolean(request.query.stale) + + const minFailuresRaw = request.query.minFailures ?? request.query.min_failures + const minFailures = + minFailuresRaw === undefined || minFailuresRaw === '' + ? undefined + : Number(minFailuresRaw) + + if (minFailures !== undefined && (!Number.isInteger(minFailures) || minFailures < 0)) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'minFailures must be a non-negative integer' }) + return + } + + const { rows, total } = await this.nip05VerificationRepository.listForAdmin({ + domain, + verified, + stale, + minFailures, + verifyExpirationMs, + limit, + offset, + }) + + response.status(200).setHeader('content-type', 'application/json').send({ + verifications: rows.map((row) => toAdminNip05Row(row, verifyExpirationMs)), + pagination: { limit, offset, total }, + verifyExpirationMs, + }) + } +} diff --git a/src/controllers/admin/get-ops-storage-counts-controller.ts b/src/controllers/admin/get-ops-storage-counts-controller.ts new file mode 100644 index 000000000..febadecf0 --- /dev/null +++ b/src/controllers/admin/get-ops-storage-counts-controller.ts @@ -0,0 +1,60 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { IEventRepository } from '../../@types/repositories' +import { createLogger } from '../../factories/logger-factory' +import { + ADMIN_OPS_STORAGE_QUERY_TIMEOUT_MS, + buildStorageCountFilters, + parseAdminOpsStorageQuery, +} from '../../utils/admin-ops-storage' + +const logger = createLogger('get-admin-ops-storage-counts-controller') + +const isStorageCountQueryTimeout = (error: unknown): boolean => + error instanceof Error && /query timeout|timed out/i.test(error.message) + +export class GetAdminOpsStorageCountsController implements IController { + public constructor(private readonly eventRepository: IEventRepository) {} + + public async handleRequest(request: Request, response: Response): Promise { + const parsed = parseAdminOpsStorageQuery(request.query.kinds, request.query.since, request.query.until) + if (parsed.error || !parsed.query) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: parsed.error ?? 'Invalid query' }) + return + } + + const filters = buildStorageCountFilters(parsed.query) + + try { + const counts = await Promise.all( + filters.map(async (filter) => ({ + kinds: filter.kinds ?? [], + count: await this.eventRepository.countByFilters([filter], { + timeoutMs: ADMIN_OPS_STORAGE_QUERY_TIMEOUT_MS, + }), + })), + ) + + response.status(200).setHeader('content-type', 'application/json').send({ + since: parsed.query.since, + until: parsed.query.until, + counts, + }) + } catch (error) { + if (isStorageCountQueryTimeout(error)) { + response + .status(504) + .setHeader('content-type', 'application/json') + .send({ error: 'Storage count query timed out' }) + return + } + + logger.error('storage count query failed: %o', error) + response.status(500).setHeader('content-type', 'application/json').send({ error: 'Storage count query failed' }) + } + } +} diff --git a/src/controllers/admin/get-ops-workers-controller.ts b/src/controllers/admin/get-ops-workers-controller.ts new file mode 100644 index 000000000..f513bfd60 --- /dev/null +++ b/src/controllers/admin/get-ops-workers-controller.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { Settings } from '../../@types/settings' +import { AdminOpsStore } from '../../utils/admin-ops-store' +import { listConfiguredWorkers, mergeWorkerRows } from '../../utils/admin-ops-config' +import { loadMergedSettings } from '../../utils/settings-config' + +export class GetAdminOpsWorkersController implements IController { + public constructor( + private readonly opsStore: AdminOpsStore, + private readonly getSettings: () => Settings = loadMergedSettings, + ) {} + + public async handleRequest(_request: Request, response: Response): Promise { + const [heartbeats, settings] = await Promise.all([this.opsStore.listWorkerHeartbeats(), this.getSettings()]) + const workers = mergeWorkerRows(listConfiguredWorkers(settings), heartbeats) + + response.status(200).setHeader('content-type', 'application/json').send({ workers }) + } +} diff --git a/src/controllers/admin/patch-moderation-limits-controller.ts b/src/controllers/admin/patch-moderation-limits-controller.ts new file mode 100644 index 000000000..8d0c9384a --- /dev/null +++ b/src/controllers/admin/patch-moderation-limits-controller.ts @@ -0,0 +1,53 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { INotificationOutboxRepository } from '../../@types/repositories' +import { adminModerationLimitsPatchSchema } from '../../schemas/admin-moderation-schema' +import { + mergeAdminModerationLimitsPatch, + readAdminModerationLimits, + ValidationError, +} from '../../utils/admin-moderation' +import { saveModerationLimitsFromRequest } from '../../utils/admin-moderation-persist' +import { validateSchema } from '../../utils/validation' + +export class PatchAdminModerationLimitsController implements IController { + public constructor(private readonly notificationOutboxRepository: INotificationOutboxRepository) {} + + public async handleRequest(request: Request, response: Response): Promise { + const validation = validateSchema(adminModerationLimitsPatchSchema)(request.body) + if (validation.error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) + return + } + + try { + await saveModerationLimitsFromRequest( + (current) => mergeAdminModerationLimitsPatch(current, validation.value), + request, + this.notificationOutboxRepository, + 'moderation.limits.updated', + ) + } catch (error) { + if (error instanceof ValidationError) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: error.message, issues: error.issues }) + return + } + + if (error instanceof Error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: error.message }) + return + } + + throw error + } + + response + .status(200) + .setHeader('content-type', 'application/json') + .send({ ok: true, limits: readAdminModerationLimits() }) + } +} diff --git a/src/controllers/admin/patch-notifications-controller.ts b/src/controllers/admin/patch-notifications-controller.ts index fbfa5f794..2e3545d49 100644 --- a/src/controllers/admin/patch-notifications-controller.ts +++ b/src/controllers/admin/patch-notifications-controller.ts @@ -21,6 +21,7 @@ import { validateSettings, } from '../../utils/settings-config' import { validateSchema } from '../../utils/validation' +import { recordAdminAuditFromRequest } from '../../utils/record-admin-audit' const logger = createLogger('patch-admin-notifications-controller') @@ -60,6 +61,7 @@ export class PatchAdminNotificationsController implements IController { changes: [{ path: 'admin.notifications', reload: 'hot-reload' }], remoteAddress: request.ip, }) + await recordAdminAuditFromRequest('notifications.updated', request) try { await this.notificationOutboxRepository.enqueue(OperatorNotificationEventType.SETTINGS_CHANGED, { diff --git a/src/controllers/admin/patch-settings-controller.ts b/src/controllers/admin/patch-settings-controller.ts index 804af2215..f67eb099e 100644 --- a/src/controllers/admin/patch-settings-controller.ts +++ b/src/controllers/admin/patch-settings-controller.ts @@ -22,6 +22,7 @@ import { INotificationOutboxRepository } from '../../@types/repositories' import { OperatorNotificationEventType } from '../../@types/operator-notifications' import { redactSettingsSecrets } from '../../utils/settings-redaction' import { createLogger } from '../../factories/logger-factory' +import { recordAdminAuditFromRequest } from '../../utils/record-admin-audit' const logger = createLogger('patch-admin-settings-controller') @@ -80,6 +81,9 @@ export class PatchAdminSettingsController implements IController { changes: updatedChanges.map(({ path, reload }) => ({ path, reload })), remoteAddress: request.ip, }) + await recordAdminAuditFromRequest('settings.updated', request, { + changes: updatedChanges.map(({ path, reload }) => ({ path, reload })), + }) try { await this.notificationOutboxRepository.enqueue(OperatorNotificationEventType.SETTINGS_CHANGED, { diff --git a/src/controllers/admin/post-admin-network-health-run-controller.ts b/src/controllers/admin/post-admin-network-health-run-controller.ts new file mode 100644 index 000000000..525b76b19 --- /dev/null +++ b/src/controllers/admin/post-admin-network-health-run-controller.ts @@ -0,0 +1,103 @@ +import { Request, Response } from 'express' + +import { ICacheAdapter } from '../../@types/adapters' +import { IController } from '../../@types/controllers' +import { IRelayProbeSnapshotStore, RelayProbeRunSnapshot } from '../../@types/relay-probe-snapshot' +import { Settings } from '../../@types/settings' +import { buildProbeOptions, RunProbeFn } from '../../app/relay-monitor-worker' +import { createLogger } from '../../factories/logger-factory' +import { recordProbeRunMetrics } from '../../telemetry/ops-metrics' +import { recordAdminAuditFromRequest } from '../../utils/record-admin-audit' +import { notifyProbeRunTransition } from '../../utils/ops-trust-notifications' +import { getEffectiveProbeIntervalSeconds, getNip66HistoryRetention } from '../../utils/nip66-schedule' +import { runProbe } from '../../utils/relay-probe' +import { RelayProbeHistoryStore } from '../../utils/relay-probe-history' +import { deriveRelayProbeRunStatus, serializeProbeResults } from '../../utils/relay-probe-snapshot' +import { + filterValidProbeTargets, + resolveProbeTargets, + resolvePublicProbeTargetKeys, +} from '../../utils/relay-probe-targets' +import { loadMergedSettings } from '../../utils/settings-config' +import { ProbeResult } from '../../utils/relay-probe/types' + +const logger = createLogger('post-admin-network-health-run') + +export class PostAdminNetworkHealthRunController implements IController { + public constructor( + private readonly snapshotStore: IRelayProbeSnapshotStore, + private readonly getSettings: () => Settings = loadMergedSettings, + private readonly probeRunner: RunProbeFn = runProbe, + private readonly historyStore?: RelayProbeHistoryStore, + private readonly opsCache?: ICacheAdapter, + ) {} + + public async handleRequest(request: Request, response: Response): Promise { + const started = Date.now() + const currentSettings = this.getSettings() + + if (!currentSettings.nip66?.enabled) { + response.status(409).setHeader('content-type', 'application/json').send({ error: 'NIP-66 monitoring is disabled' }) + return + } + + const configuredTargets = resolveProbeTargets(currentSettings) + const { valid, invalid } = filterValidProbeTargets(configuredTargets) + + if (valid.length === 0) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: 'No valid probe targets configured', invalid }) + return + } + + const probeOptions = buildProbeOptions(currentSettings) + const results: ProbeResult[] = [] + + for (const target of valid) { + try { + results.push(await this.probeRunner(target, probeOptions)) + } catch (error) { + logger.error('manual probe failed for %s: %o', target, error) + } + } + + if (results.length === 0) { + response.status(502).setHeader('content-type', 'application/json').send({ error: 'Probe run produced no results' }) + return + } + + const publicTargetKeys = resolvePublicProbeTargetKeys(currentSettings) + const snapshot: RelayProbeRunSnapshot = { + runAt: new Date().toISOString(), + targets: valid, + results: serializeProbeResults(results), + status: deriveRelayProbeRunStatus(results), + probeContext: { + configuredRelayUrl: currentSettings.info?.relay_url, + publicTargetKeys: [...publicTargetKeys], + }, + } + + const expirySeconds = getEffectiveProbeIntervalSeconds(currentSettings) * 2 + await this.snapshotStore.saveLatest(snapshot, expirySeconds) + + if (this.historyStore) { + try { + await this.historyStore.append(snapshot, getNip66HistoryRetention(currentSettings)) + } catch (error) { + logger.error('failed to save probe history: %o', error) + } + } + + recordProbeRunMetrics(snapshot.status, Date.now() - started) + await notifyProbeRunTransition(this.opsCache, snapshot) + await recordAdminAuditFromRequest('probe.manual_run', request, { + status: snapshot.status, + targets: valid, + }) + + response.status(200).setHeader('content-type', 'application/json').send({ snapshot }) + } +} diff --git a/src/controllers/admin/post-moderation-actions-controller.ts b/src/controllers/admin/post-moderation-actions-controller.ts new file mode 100644 index 000000000..bee566dd2 --- /dev/null +++ b/src/controllers/admin/post-moderation-actions-controller.ts @@ -0,0 +1,70 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { INotificationOutboxRepository } from '../../@types/repositories' +import { adminModerationActionSchema } from '../../schemas/admin-moderation-schema' +import { + applyAdminModerationAction, + readAdminModerationLimits, + ValidationError, +} from '../../utils/admin-moderation' +import { saveModerationLimitsFromRequest } from '../../utils/admin-moderation-persist' +import { validateSchema } from '../../utils/validation' + +export class PostAdminModerationActionsController implements IController { + public constructor(private readonly notificationOutboxRepository: INotificationOutboxRepository) {} + + public async handleRequest(request: Request, response: Response): Promise { + const validation = validateSchema(adminModerationActionSchema)(request.body) + if (validation.error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) + return + } + + const { action, pubkey, ip } = validation.value + + try { + applyAdminModerationAction(readAdminModerationLimits(), action, { pubkey, ip }) + } catch (error) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: error instanceof Error ? error.message : 'Invalid action' }) + return + } + + try { + await saveModerationLimitsFromRequest( + (current) => applyAdminModerationAction(current, action, { pubkey, ip }), + request, + this.notificationOutboxRepository, + 'moderation.action', + { + action, + pubkey, + ip, + }, + ) + } catch (error) { + if (error instanceof ValidationError) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: error.message, issues: error.issues }) + return + } + + if (error instanceof Error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: error.message }) + return + } + + throw error + } + + response + .status(200) + .setHeader('content-type', 'application/json') + .send({ ok: true, limits: readAdminModerationLimits() }) + } +} diff --git a/src/controllers/admin/post-ops-nip05-reverify-controller.ts b/src/controllers/admin/post-ops-nip05-reverify-controller.ts new file mode 100644 index 000000000..57adc09d7 --- /dev/null +++ b/src/controllers/admin/post-ops-nip05-reverify-controller.ts @@ -0,0 +1,49 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { INip05VerificationRepository } from '../../@types/repositories' +import { Settings } from '../../@types/settings' +import { getNip05VerifyExpirationMs, toAdminNip05Row } from '../../utils/admin-ops-nip05' +import { reverifyNip05Verification } from '../../utils/nip05-reverification' +import { loadMergedSettings } from '../../utils/settings-config' +import { pubkeySchema } from '../../schemas/base-schema' +import { recordAdminAuditFromRequest } from '../../utils/record-admin-audit' + +export class PostAdminOpsNip05ReverifyController implements IController { + public constructor( + private readonly nip05VerificationRepository: INip05VerificationRepository, + private readonly getSettings: () => Settings = loadMergedSettings, + ) {} + + public async handleRequest(request: Request, response: Response): Promise { + const pubkeyResult = pubkeySchema.safeParse(request.params.pubkey) + if (!pubkeyResult.success) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid pubkey' }) + return + } + + const settings = this.getSettings() + if (!settings.nip05 || settings.nip05.mode === 'disabled') { + response.status(409).setHeader('content-type', 'application/json').send({ error: 'NIP-05 verification is disabled' }) + return + } + + const existing = await this.nip05VerificationRepository.findByPubkey(pubkeyResult.data) + if (!existing) { + response.status(404).setHeader('content-type', 'application/json').send({ error: 'Verification record not found' }) + return + } + + try { + const updated = await reverifyNip05Verification(existing, this.nip05VerificationRepository) + await recordAdminAuditFromRequest('nip05.reverify', request, { pubkey: pubkeyResult.data }) + const verifyExpirationMs = getNip05VerifyExpirationMs(settings) + response + .status(200) + .setHeader('content-type', 'application/json') + .send({ verification: toAdminNip05Row(updated, verifyExpirationMs) }) + } catch { + response.status(502).setHeader('content-type', 'application/json').send({ error: 'Re-verification failed' }) + } + } +} diff --git a/src/controllers/admin/post-settings-restore-controller.ts b/src/controllers/admin/post-settings-restore-controller.ts index aadcca090..aded18e40 100644 --- a/src/controllers/admin/post-settings-restore-controller.ts +++ b/src/controllers/admin/post-settings-restore-controller.ts @@ -4,6 +4,7 @@ import { IController } from '../../@types/controllers' import { adminSettingsRestoreBodySchema } from '../../schemas/admin-settings-schema' import { appendSettingsAuditLog, restoreSettingsBackup } from '../../utils/settings-config' import { validateSchema } from '../../utils/validation' +import { recordAdminAuditFromRequest } from '../../utils/record-admin-audit' export class PostAdminSettingsRestoreController implements IController { public async handleRequest(request: Request, response: Response): Promise { @@ -20,6 +21,7 @@ export class PostAdminSettingsRestoreController implements IController { filename: validation.value.filename, remoteAddress: request.ip, }) + await recordAdminAuditFromRequest('settings.restored', request, { filename: validation.value.filename }) response.status(200).setHeader('content-type', 'application/json').send({ ok: true, filename: validation.value.filename }) } catch (error) { diff --git a/src/controllers/admission/get-admission-check-controller.ts b/src/controllers/admission/get-admission-check-controller.ts index f68e8025a..388325495 100644 --- a/src/controllers/admission/get-admission-check-controller.ts +++ b/src/controllers/admission/get-admission-check-controller.ts @@ -53,7 +53,7 @@ export class GetSubmissionCheckController implements IController { if (Array.isArray(ipWhitelist) && !ipWhitelist.includes(remoteAddress)) { const rateLimiter = this.rateLimiter() for (const { rate, period } of rateLimits) { - if (await rateLimiter.hit(`${remoteAddress}:admission-check:${period}`, 1, { period, rate })) { + if ((await rateLimiter.hit(`${remoteAddress}:admission-check:${period}`, 1, { period, rate })).limited) { logger('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period) limited = true } diff --git a/src/controllers/invoices/get-invoice-controller.ts b/src/controllers/invoices/get-invoice-controller.ts index 510d9b92a..a2e3f0737 100644 --- a/src/controllers/invoices/get-invoice-controller.ts +++ b/src/controllers/invoices/get-invoice-controller.ts @@ -4,19 +4,44 @@ import { Request, Response } from 'express' import { createSettings } from '../../factories/settings-factory' -import { FeeSchedule } from '../../@types/settings' +import { FeeSchedule, Settings } from '../../@types/settings' import { IController } from '../../@types/controllers' +import { areSubscriptionsEnabled, getSubscriptionPlans } from '../../utils/subscription' import { getTemplate } from '../../utils/template-cache' import { getPublicPathPrefix } from '../../utils/http' +const ADMISSION_DESCRIPTION = + 'This Nostr relay requires a one-time admission fee.
Once payment is complete, you will be able to post and fetch events from this relay.' + +const SUBSCRIPTION_DESCRIPTION = + 'This Nostr relay requires a subscription.
Choose a plan. Once payment is complete, you will be able to post and fetch events from this relay until the period ends.' + +const SECONDS_PER_DAY = 86400 + +const renderPlanFields = (settings: Settings): string => { + const options = getSubscriptionPlans(settings) + .map((plan, index) => { + const days = plan.intervalSeconds / SECONDS_PER_DAY + const period = Number.isInteger(days) ? `${days} day${days === 1 ? '' : 's'}` : `${plan.intervalSeconds} seconds` + const label = `${plan.id}: ${(BigInt(plan.amount) / 1000n).toString()} sats per ${period}` + const id = `plan-${index}` + + return `
` + }) + .join('') + + return `
Plan${options}
` +} + export class GetInvoiceController implements IController { public async handleRequest(req: Request, res: Response): Promise { const settings = createSettings() + const subscriptionsEnabled = areSubscriptionsEnabled(settings) if ( pathEq(['payments', 'enabled'], true, settings) && - pathEq(['payments', 'feeSchedules', 'admission', '0', 'enabled'], true, settings) + (subscriptionsEnabled || pathEq(['payments', 'feeSchedules', 'admission', '0', 'enabled'], true, settings)) ) { const name = path(['info', 'name'])(settings) const feeSchedule = path(['payments', 'feeSchedules', 'admission', '0'], settings) @@ -24,7 +49,12 @@ export class GetInvoiceController implements IController { .replaceAll('{{name}}', escapeHtml(name)) .replaceAll('{{path_prefix}}', escapeHtml(getPublicPathPrefix(req, settings))) .replaceAll('{{processor_json}}', safeJsonForScript(settings.payments.processor)) - .replaceAll('{{amount}}', (BigInt(feeSchedule.amount) / 1000n).toString()) + .replaceAll('{{fee_description_html}}', subscriptionsEnabled ? SUBSCRIPTION_DESCRIPTION : ADMISSION_DESCRIPTION) + .replaceAll('{{plan_fields_html}}', subscriptionsEnabled ? renderPlanFields(settings) : '') + .replaceAll( + '{{pay_label}}', + subscriptionsEnabled ? 'Pay' : `Pay ${(BigInt(feeSchedule.amount) / 1000n).toString()} sats`, + ) .replaceAll('{{nonce}}', res.locals.nonce) res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(page) diff --git a/src/controllers/invoices/post-invoice-controller.ts b/src/controllers/invoices/post-invoice-controller.ts index f35443dcf..bc803e3c1 100644 --- a/src/controllers/invoices/post-invoice-controller.ts +++ b/src/controllers/invoices/post-invoice-controller.ts @@ -11,6 +11,7 @@ import { IUserRepository } from '../../@types/repositories' import { createLogger } from '../../factories/logger-factory' +import { areSubscriptionsEnabled, findSubscriptionPlan } from '../../utils/subscription' import { escapeHtml, safeJsonForScript } from '../../utils/html' import { fromBech32, toBech32 } from '../../utils/transform' import { getPublicKey, getRelayPrivateKey } from '../../utils/event' @@ -57,8 +58,17 @@ export class PostInvoiceController implements IController { return } - const isAdmissionInvoice = request.body?.feeSchedule === 'admission' - if (!isAdmissionInvoice) { + const subscriptionsEnabled = areSubscriptionsEnabled(currentSettings) + const planId = request.body?.planId + const plan = + subscriptionsEnabled && typeof planId === 'string' ? findSubscriptionPlan(currentSettings, planId) : undefined + if (subscriptionsEnabled) { + if (!plan) { + response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Invalid plan') + + return + } + } else if (request.body?.feeSchedule !== 'admission') { response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Invalid fee') return @@ -87,37 +97,43 @@ export class PostInvoiceController implements IController { return } - const isApplicableFee = (feeSchedule: FeeSchedule) => - feeSchedule.enabled && !feeSchedule.whitelists?.pubkeys?.includes(pubkey) - const admissionFee = currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee) ?? [] + let amount: bigint + if (plan) { + // Renewing early, or moving to another plan, is allowed, so an admitted user is not turned away. + amount = BigInt(plan.amount) + } else { + const isApplicableFee = (feeSchedule: FeeSchedule) => + feeSchedule.enabled && !feeSchedule.whitelists?.pubkeys?.includes(pubkey) + const admissionFee = currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee) ?? [] - if (!Array.isArray(admissionFee) || !admissionFee.length) { - response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('No admission fee required') + if (!Array.isArray(admissionFee) || !admissionFee.length) { + response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('No admission fee required') - return - } + return + } - const minBalance = currentSettings.limits?.event?.pubkey?.minBalance - const user = await this.userRepository.findByPubkey(pubkey) - if (user && user.isAdmitted && (!minBalance || user.balance >= minBalance)) { - response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('User is already admitted.') + const minBalance = currentSettings.limits?.event?.pubkey?.minBalance + const user = await this.userRepository.findByPubkey(pubkey) + if (user && user.isAdmitted && (!minBalance || user.balance >= minBalance)) { + response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('User is already admitted.') - return + return + } + + amount = admissionFee.reduce((sum, fee) => BigInt(fee.amount) + sum, 0n) } let invoice: Invoice - const amount = admissionFee.reduce((sum, fee) => BigInt(fee.amount) + sum, 0n) try { - const description = `${relayName} Admission Fee for ${toBech32('npub')(pubkey)}` + const description = plan + ? `${relayName} ${plan.id} subscription for ${toBech32('npub')(pubkey)}` + : `${relayName} Admission Fee for ${toBech32('npub')(pubkey)}` - invoice = await this.paymentsService.createInvoice(pubkey, amount, description) + invoice = await this.paymentsService.createInvoice(pubkey, amount, description, plan?.id) } catch (error) { logger.error('Unable to create invoice. Reason:', error) - response - .status(500) - .setHeader('content-type', 'text/plain') - .send('Unable to create invoice') + response.status(500).setHeader('content-type', 'text/plain').send('Unable to create invoice') return } @@ -134,6 +150,7 @@ export class PostInvoiceController implements IController { .replaceAll('{{relay_url_html}}', escapeHtml(relayUrl)) .replaceAll('{{invoice_html}}', escapeHtml(invoice.bolt11)) .replaceAll('{{pubkey_html}}', escapeHtml(pubkey)) + .replaceAll('{{plan_id_html}}', escapeHtml(plan?.id ?? '')) .replaceAll('{{path_prefix}}', escapeHtml(pathPrefix)) .replaceAll('{{amount}}', (amount / 1000n).toString()) // JS contexts — safeJsonForScript serializes and escapes < to prevent injection @@ -166,7 +183,7 @@ export class PostInvoiceController implements IController { if (Array.isArray(ipWhitelist) && !ipWhitelist.includes(remoteAddress)) { const rateLimiter = this.rateLimiter() for (const { rate, period } of rateLimits) { - if (await rateLimiter.hit(`${remoteAddress}:invoice:${period}`, 1, { period, rate })) { + if ((await rateLimiter.hit(`${remoteAddress}:invoice:${period}`, 1, { period, rate })).limited) { logger('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period) limited = true } diff --git a/src/controllers/subscriptions/get-subscription-status-controller.ts b/src/controllers/subscriptions/get-subscription-status-controller.ts new file mode 100644 index 000000000..ad76fe60b --- /dev/null +++ b/src/controllers/subscriptions/get-subscription-status-controller.ts @@ -0,0 +1,136 @@ +import { path } from 'ramda' + +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { IRateLimiter } from '../../@types/utils' +import { IUserSubscriptionRepository } from '../../@types/repositories' +import { Settings, SubscriptionPlan } from '../../@types/settings' +import { SubscriptionStatus, UserSubscription } from '../../@types/user-subscription' + +import { createLogger } from '../../factories/logger-factory' + +import { + areSubscriptionsEnabled, + findSubscriptionPlan, + getAccessEndsAt, + hasSubscriptionAccess, +} from '../../utils/subscription' +import { fromNpub } from '../../utils/transform' +import { getRemoteAddress } from '../../utils/http' + +const logger = createLogger('get-subscription-status-controller') + +export class GetSubscriptionStatusController implements IController { + public constructor( + private readonly userSubscriptionRepository: IUserSubscriptionRepository, + private readonly settings: () => Settings, + private readonly rateLimiter: () => IRateLimiter, + ) {} + + public async handleRequest(request: Request, response: Response): Promise { + // The endpoint is public, so a client served from another site may read it, errors included. + response + .setHeader('access-control-allow-origin', '*') + .setHeader('access-control-allow-headers', '*') + .setHeader('access-control-allow-methods', 'GET, OPTIONS') + + const currentSettings = this.settings() + + if (await this.isRateLimited(request, currentSettings)) { + response.status(429).setHeader('content-type', 'text/plain; charset=utf8').send('Too many requests') + return + } + + if (!areSubscriptionsEnabled(currentSettings)) { + response.status(404).setHeader('content-type', 'text/plain; charset=utf8').send('Subscriptions are not enabled') + return + } + + const pubkey = this.parsePubkey(request.query?.pubkey) + if (!pubkey) { + response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Invalid pubkey') + return + } + + const subscription = await this.userSubscriptionRepository.findByPubkey(pubkey) + + // Without a row the user has no plan to report: they were never subscribed, or were admitted before plans. + const body = subscription + ? { + pubkey, + planId: subscription.planId, + status: subscription.status, + active: hasSubscriptionAccess(subscription, new Date()), + currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null, + accessEndsAt: + this.getAccessEndedOrEndsAt( + subscription, + findSubscriptionPlan(currentSettings, subscription.planId), + )?.toISOString() ?? null, + } + : { pubkey, planId: null, status: null, active: false, currentPeriodEnd: null, accessEndsAt: null } + + response.status(200).setHeader('content-type', 'application/json; charset=utf8').send(body) + } + + /** + * When access ends, or ended. For an expired subscription `getAccessEndsAt` gives a placeholder in the past to block + * writes; the date to report is the period end plus the grace the plan allowed. + */ + private getAccessEndedOrEndsAt(subscription: UserSubscription, plan: SubscriptionPlan | undefined): Date | undefined { + if (subscription.status !== SubscriptionStatus.EXPIRED) { + return getAccessEndsAt(subscription) + } + + if (!subscription.currentPeriodEnd) { + return undefined + } + + return new Date(subscription.currentPeriodEnd.getTime() + (plan?.graceSeconds ?? 0) * 1000) + } + + private parsePubkey(raw: unknown): string | undefined { + if (typeof raw !== 'string') { + return undefined + } + + if (/^[0-9a-f]{64}$/.test(raw)) { + return raw + } + + if (/^npub1/i.test(raw)) { + try { + return fromNpub(raw) + } catch (_error) { + return undefined + } + } + + return undefined + } + + /** Shares the invoice limits, since this endpoint sits beside them. */ + private async isRateLimited(request: Request, settings: Settings): Promise { + const rateLimits = path(['limits', 'invoice', 'rateLimits'], settings) + if (!Array.isArray(rateLimits) || !rateLimits.length) { + return false + } + + const ipWhitelist = path(['limits', 'invoice', 'ipWhitelist'], settings) + const remoteAddress = getRemoteAddress(request, settings) + + let limited = false + if (Array.isArray(ipWhitelist) && !ipWhitelist.includes(remoteAddress)) { + const rateLimiter = this.rateLimiter() + for (const { rate, period } of rateLimits) { + if ((await rateLimiter.hit(`${remoteAddress}:subscription-status:${period}`, 1, { period, rate })).limited) { + logger('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period) + limited = true + } + } + } + + return limited + } +} diff --git a/src/factories/admin-auth-provider-factory.ts b/src/factories/admin-auth-provider-factory.ts index 162670231..501f95abe 100644 --- a/src/factories/admin-auth-provider-factory.ts +++ b/src/factories/admin-auth-provider-factory.ts @@ -1,7 +1,10 @@ import { PasswordAdminAuthProvider } from '../admin/password-admin-auth-provider' import { IAdminAuthProvider } from '../@types/admin' +import { recordAdminAuditFromRequest } from '../utils/record-admin-audit' import { createSettings } from './settings-factory' export const createAdminAuthProvider = (): IAdminAuthProvider => { - return new PasswordAdminAuthProvider(createSettings) + return new PasswordAdminAuthProvider(createSettings, async (event, request) => { + await recordAdminAuditFromRequest(event === 'login' ? 'admin.login' : 'admin.logout', request) + }) } diff --git a/src/factories/admin-ops-cache-adapter-factory.ts b/src/factories/admin-ops-cache-adapter-factory.ts new file mode 100644 index 000000000..72cfefbb0 --- /dev/null +++ b/src/factories/admin-ops-cache-adapter-factory.ts @@ -0,0 +1,11 @@ +import { RedisAdapter } from '../adapters/redis-adapter' +import { ICacheAdapter } from '../@types/adapters' +import { getCacheClient } from '../cache/client' + +export const createAdminOpsCacheAdapter = (): ICacheAdapter | undefined => { + try { + return new RedisAdapter(getCacheClient()) + } catch { + return undefined + } +} diff --git a/src/factories/controllers/get-admin-audit-log-controller-factory.ts b/src/factories/controllers/get-admin-audit-log-controller-factory.ts new file mode 100644 index 000000000..df7a5c9db --- /dev/null +++ b/src/factories/controllers/get-admin-audit-log-controller-factory.ts @@ -0,0 +1,10 @@ +import { RedisAdapter } from '../../adapters/redis-adapter' +import { IController } from '../../@types/controllers' +import { getCacheClient } from '../../cache/client' +import { GetAdminAuditLogController } from '../../controllers/admin/get-admin-audit-log-controller' +import { AdminAuditStore } from '../../utils/admin-audit-store' + +export const createGetAdminAuditLogController = (): IController => { + const auditStore = new AdminAuditStore(new RedisAdapter(getCacheClient())) + return new GetAdminAuditLogController(auditStore) +} diff --git a/src/factories/controllers/get-admin-diagnostics-export-controller-factory.ts b/src/factories/controllers/get-admin-diagnostics-export-controller-factory.ts new file mode 100644 index 000000000..eb630c141 --- /dev/null +++ b/src/factories/controllers/get-admin-diagnostics-export-controller-factory.ts @@ -0,0 +1,7 @@ +import { IController } from '../../@types/controllers' +import { GetAdminDiagnosticsExportController } from '../../controllers/admin/get-admin-diagnostics-export-controller' +import { createSettings } from '../settings-factory' + +export const createGetAdminDiagnosticsExportController = (): IController => { + return new GetAdminDiagnosticsExportController(createSettings) +} diff --git a/src/factories/controllers/get-admin-moderation-limits-controller-factory.ts b/src/factories/controllers/get-admin-moderation-limits-controller-factory.ts new file mode 100644 index 000000000..bc7340381 --- /dev/null +++ b/src/factories/controllers/get-admin-moderation-limits-controller-factory.ts @@ -0,0 +1,4 @@ +import { GetAdminModerationLimitsController } from '../../controllers/admin/get-moderation-limits-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminModerationLimitsController = (): IController => new GetAdminModerationLimitsController() diff --git a/src/factories/controllers/get-admin-moderation-stats-controller-factory.ts b/src/factories/controllers/get-admin-moderation-stats-controller-factory.ts new file mode 100644 index 000000000..dd4e09316 --- /dev/null +++ b/src/factories/controllers/get-admin-moderation-stats-controller-factory.ts @@ -0,0 +1,4 @@ +import { GetAdminModerationStatsController } from '../../controllers/admin/get-moderation-stats-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminModerationStatsController = (): IController => new GetAdminModerationStatsController() diff --git a/src/factories/controllers/get-admin-network-health-history-controller-factory.ts b/src/factories/controllers/get-admin-network-health-history-controller-factory.ts new file mode 100644 index 000000000..ac9146215 --- /dev/null +++ b/src/factories/controllers/get-admin-network-health-history-controller-factory.ts @@ -0,0 +1,11 @@ +import { RedisAdapter } from '../../adapters/redis-adapter' +import { IController } from '../../@types/controllers' +import { getCacheClient } from '../../cache/client' +import { GetAdminNetworkHealthHistoryController } from '../../controllers/admin/get-network-health-history-controller' +import { RelayProbeHistoryStore } from '../../utils/relay-probe-history' + +export const createGetAdminNetworkHealthHistoryController = (): IController => { + const historyStore = new RelayProbeHistoryStore(new RedisAdapter(getCacheClient())) + + return new GetAdminNetworkHealthHistoryController(historyStore) +} diff --git a/src/factories/controllers/get-admin-ops-nip05-verifications-controller-factory.ts b/src/factories/controllers/get-admin-ops-nip05-verifications-controller-factory.ts new file mode 100644 index 000000000..d2b80c08f --- /dev/null +++ b/src/factories/controllers/get-admin-ops-nip05-verifications-controller-factory.ts @@ -0,0 +1,8 @@ +import { IController } from '../../@types/controllers' +import { getMasterDbClient } from '../../database/client' +import { GetAdminOpsNip05VerificationsController } from '../../controllers/admin/get-ops-nip05-verifications-controller' +import { Nip05VerificationRepository } from '../../repositories/nip05-verification-repository' + +export const createGetAdminOpsNip05VerificationsController = (): IController => { + return new GetAdminOpsNip05VerificationsController(new Nip05VerificationRepository(getMasterDbClient())) +} diff --git a/src/factories/controllers/get-admin-ops-storage-counts-controller-factory.ts b/src/factories/controllers/get-admin-ops-storage-counts-controller-factory.ts new file mode 100644 index 000000000..2955a0e97 --- /dev/null +++ b/src/factories/controllers/get-admin-ops-storage-counts-controller-factory.ts @@ -0,0 +1,9 @@ +import { IController } from '../../@types/controllers' +import { getMasterDbClient, getReadReplicaDbClient } from '../../database/client' +import { GetAdminOpsStorageCountsController } from '../../controllers/admin/get-ops-storage-counts-controller' +import { createEventRepository } from '../event-repository-factory' + +export const createGetAdminOpsStorageCountsController = (): IController => { + const eventRepository = createEventRepository(getMasterDbClient(), getReadReplicaDbClient()) + return new GetAdminOpsStorageCountsController(eventRepository) +} diff --git a/src/factories/controllers/get-admin-ops-workers-controller-factory.ts b/src/factories/controllers/get-admin-ops-workers-controller-factory.ts new file mode 100644 index 000000000..c34e0e25b --- /dev/null +++ b/src/factories/controllers/get-admin-ops-workers-controller-factory.ts @@ -0,0 +1,10 @@ +import { RedisAdapter } from '../../adapters/redis-adapter' +import { IController } from '../../@types/controllers' +import { getCacheClient } from '../../cache/client' +import { GetAdminOpsWorkersController } from '../../controllers/admin/get-ops-workers-controller' +import { AdminOpsStore } from '../../utils/admin-ops-store' + +export const createGetAdminOpsWorkersController = (): IController => { + const opsStore = new AdminOpsStore(new RedisAdapter(getCacheClient())) + return new GetAdminOpsWorkersController(opsStore) +} diff --git a/src/factories/controllers/get-subscription-status-controller-factory.ts b/src/factories/controllers/get-subscription-status-controller-factory.ts new file mode 100644 index 000000000..938f1c88f --- /dev/null +++ b/src/factories/controllers/get-subscription-status-controller-factory.ts @@ -0,0 +1,12 @@ +import { createSettings } from '../settings-factory' +import { getMasterDbClient } from '../../database/client' +import { GetSubscriptionStatusController } from '../../controllers/subscriptions/get-subscription-status-controller' +import { rateLimiterFactory } from '../rate-limiter-factory' +import { UserSubscriptionRepository } from '../../repositories/user-subscription-repository' + +export const createGetSubscriptionStatusController = () => + new GetSubscriptionStatusController( + new UserSubscriptionRepository(getMasterDbClient()), + createSettings, + rateLimiterFactory, + ) diff --git a/src/factories/controllers/patch-admin-moderation-limits-controller-factory.ts b/src/factories/controllers/patch-admin-moderation-limits-controller-factory.ts new file mode 100644 index 000000000..cb3724f0e --- /dev/null +++ b/src/factories/controllers/patch-admin-moderation-limits-controller-factory.ts @@ -0,0 +1,9 @@ +import { PatchAdminModerationLimitsController } from '../../controllers/admin/patch-moderation-limits-controller' +import { IController } from '../../@types/controllers' +import { getMasterDbClient } from '../../database/client' +import { NotificationOutboxRepository } from '../../repositories/notification-outbox-repository' + +export const createPatchAdminModerationLimitsController = (): IController => { + const notificationOutboxRepository = new NotificationOutboxRepository(getMasterDbClient()) + return new PatchAdminModerationLimitsController(notificationOutboxRepository) +} diff --git a/src/factories/controllers/post-admin-moderation-actions-controller-factory.ts b/src/factories/controllers/post-admin-moderation-actions-controller-factory.ts new file mode 100644 index 000000000..7e6f8b5d7 --- /dev/null +++ b/src/factories/controllers/post-admin-moderation-actions-controller-factory.ts @@ -0,0 +1,9 @@ +import { PostAdminModerationActionsController } from '../../controllers/admin/post-moderation-actions-controller' +import { IController } from '../../@types/controllers' +import { getMasterDbClient } from '../../database/client' +import { NotificationOutboxRepository } from '../../repositories/notification-outbox-repository' + +export const createPostAdminModerationActionsController = (): IController => { + const notificationOutboxRepository = new NotificationOutboxRepository(getMasterDbClient()) + return new PostAdminModerationActionsController(notificationOutboxRepository) +} diff --git a/src/factories/controllers/post-admin-network-health-run-controller-factory.ts b/src/factories/controllers/post-admin-network-health-run-controller-factory.ts new file mode 100644 index 000000000..783b86f6b --- /dev/null +++ b/src/factories/controllers/post-admin-network-health-run-controller-factory.ts @@ -0,0 +1,21 @@ +import { RedisAdapter } from '../../adapters/redis-adapter' +import { IController } from '../../@types/controllers' +import { getCacheClient } from '../../cache/client' +import { PostAdminNetworkHealthRunController } from '../../controllers/admin/post-admin-network-health-run-controller' +import { createSettings } from '../settings-factory' +import { RelayProbeHistoryStore } from '../../utils/relay-probe-history' +import { RelayProbeSnapshotStore } from '../../utils/relay-probe-snapshot' + +export const createPostAdminNetworkHealthRunController = (): IController => { + const cacheAdapter = new RedisAdapter(getCacheClient()) + const snapshotStore = new RelayProbeSnapshotStore(cacheAdapter) + const historyStore = new RelayProbeHistoryStore(cacheAdapter) + + return new PostAdminNetworkHealthRunController( + snapshotStore, + createSettings, + undefined, + historyStore, + cacheAdapter, + ) +} diff --git a/src/factories/controllers/post-admin-ops-nip05-reverify-controller-factory.ts b/src/factories/controllers/post-admin-ops-nip05-reverify-controller-factory.ts new file mode 100644 index 000000000..fe3343c7a --- /dev/null +++ b/src/factories/controllers/post-admin-ops-nip05-reverify-controller-factory.ts @@ -0,0 +1,8 @@ +import { IController } from '../../@types/controllers' +import { getMasterDbClient } from '../../database/client' +import { PostAdminOpsNip05ReverifyController } from '../../controllers/admin/post-ops-nip05-reverify-controller' +import { Nip05VerificationRepository } from '../../repositories/nip05-verification-repository' + +export const createPostAdminOpsNip05ReverifyController = (): IController => { + return new PostAdminOpsNip05ReverifyController(new Nip05VerificationRepository(getMasterDbClient())) +} diff --git a/src/factories/maintenance-worker-factory.ts b/src/factories/maintenance-worker-factory.ts index 27fd53115..dd2e4a908 100644 --- a/src/factories/maintenance-worker-factory.ts +++ b/src/factories/maintenance-worker-factory.ts @@ -1,6 +1,8 @@ +import { createAdminOpsCacheAdapter } from './admin-ops-cache-adapter-factory' import { createMaintenanceService } from './maintenance-service-factory' import { createNotificationOutboxService } from './notification-outbox-service-factory' import { createPaymentsService } from './payments-service-factory' +import { createSubscriptionService } from './subscription-service-factory' import { createSettings } from './settings-factory' import { getMasterDbClient } from '../database/client' import { MaintenanceWorker } from '../app/maintenance-worker' @@ -22,5 +24,7 @@ export const maintenanceWorkerFactory = () => { createNotificationOutboxService(), notificationDeliveryLogRepository, notificationOutboxRepository, + createAdminOpsCacheAdapter(), + createSubscriptionService(), ) } diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index b11f3339e..964460b0b 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -13,9 +13,14 @@ import { getCacheClient } from '../cache/client' import { AuthMessageHandler } from '../handlers/auth-message-handler' import { CountMessageHandler } from '../handlers/count-message-handler' import { EventMessageHandler } from '../handlers/event-message-handler' +import { NegCloseMessageHandler } from '../handlers/neg-close-message-handler' +import { NegMsgMessageHandler } from '../handlers/neg-msg-message-handler' +import { NegOpenMessageHandler } from '../handlers/neg-open-message-handler' import { SubscribeMessageHandler } from '../handlers/subscribe-message-handler' import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handler' import { eventStrategyFactory } from './event-strategy-factory' +import { getMasterDbClient } from '../database/client' +import { UserSubscriptionRepository } from '../repositories/user-subscription-repository' import { rateLimiterFactory } from './rate-limiter-factory' import { createSettings } from './settings-factory' import { wotGraphServiceFactory } from './wot-graph-service-factory' @@ -28,6 +33,14 @@ export const getCache = (): ICacheAdapter => { return cacheAdapter } +let userSubscriptionRepository: UserSubscriptionRepository | undefined = undefined +const getUserSubscriptionRepository = (): UserSubscriptionRepository => { + if (!userSubscriptionRepository) { + userSubscriptionRepository = new UserSubscriptionRepository(getMasterDbClient()) + } + return userSubscriptionRepository +} + export const messageHandlerFactory = ( eventRepository: IEventRepository, @@ -58,6 +71,7 @@ export const messageHandlerFactory = getCache(), rateLimiterFactory, wotGraphServiceFactory(getCache(), eventRepository, createSettings), + getUserSubscriptionRepository(), ) } case MessageType.REQ: @@ -74,6 +88,12 @@ export const messageHandlerFactory = return new CountMessageHandler(adapter, eventRepository, createSettings) case MessageType.AUTH: return new AuthMessageHandler(adapter, createSettings) + case MessageType.NEG_OPEN: + return new NegOpenMessageHandler(adapter, eventRepository, createSettings) + case MessageType.NEG_MSG: + return new NegMsgMessageHandler(adapter, createSettings) + case MessageType.NEG_CLOSE: + return new NegCloseMessageHandler(adapter) default: throw new Error(`Unknown message type: ${String(message[0]).substring(0, 64)}`) } diff --git a/src/factories/payments-service-factory.ts b/src/factories/payments-service-factory.ts index c16ebb16a..07e3bcf59 100644 --- a/src/factories/payments-service-factory.ts +++ b/src/factories/payments-service-factory.ts @@ -4,6 +4,7 @@ import { createPaymentsProcessor } from './payments-processor-factory' import { createSettings } from './settings-factory' import { InvoiceRepository } from '../repositories/invoice-repository' import { PaymentsService } from '../services/payments-service' +import { UserSubscriptionRepository } from '../repositories/user-subscription-repository' import { UserRepository } from '../repositories/user-repository' import { NotificationOutboxRepository } from '../repositories/notification-outbox-repository' @@ -24,5 +25,6 @@ export const createPaymentsService = () => { eventRepository, createSettings, notificationOutboxRepository, + new UserSubscriptionRepository(dbClient), ) } diff --git a/src/factories/relay-monitor-worker-factory.ts b/src/factories/relay-monitor-worker-factory.ts index d2f0afe2e..8f7d8d5f8 100644 --- a/src/factories/relay-monitor-worker-factory.ts +++ b/src/factories/relay-monitor-worker-factory.ts @@ -2,17 +2,28 @@ import { RedisAdapter } from '../adapters/redis-adapter' import { RelayMonitorWorker } from '../app/relay-monitor-worker' import { getCacheClient } from '../cache/client' import { getMasterDbClient, getReadReplicaDbClient } from '../database/client' +import { createAdminOpsCacheAdapter } from './admin-ops-cache-adapter-factory' import { createEventRepository } from './event-repository-factory' import { createSettings } from './settings-factory' import { Nip66EventPublisher } from '../services/nip66-event-publisher' +import { RelayProbeHistoryStore } from '../utils/relay-probe-history' import { RelayProbeSnapshotStore } from '../utils/relay-probe-snapshot' import { runProbe } from '../utils/relay-probe' export const relayMonitorWorkerFactory = () => { const cache = new RedisAdapter(getCacheClient()) const snapshotStore = new RelayProbeSnapshotStore(cache) + const historyStore = new RelayProbeHistoryStore(cache) const eventRepository = createEventRepository(getMasterDbClient(), getReadReplicaDbClient()) const eventPublisher = new Nip66EventPublisher(eventRepository, cache) - return new RelayMonitorWorker(process, createSettings, snapshotStore, runProbe, eventPublisher) + return new RelayMonitorWorker( + process, + createSettings, + snapshotStore, + runProbe, + eventPublisher, + historyStore, + createAdminOpsCacheAdapter(), + ) } diff --git a/src/factories/static-mirroring.worker-factory.ts b/src/factories/static-mirroring.worker-factory.ts deleted file mode 100644 index 5516848c5..000000000 --- a/src/factories/static-mirroring.worker-factory.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getMasterDbClient, getReadReplicaDbClient } from '../database/client' -import { createEventRepository } from './event-repository-factory' -import { createSettings } from './settings-factory' -import { StaticMirroringWorker } from '../app/static-mirroring-worker' -import { UserRepository } from '../repositories/user-repository' - -export const staticMirroringWorkerFactory = () => { - const dbClient = getMasterDbClient() - const readReplicaDbClient = getReadReplicaDbClient() - const eventRepository = createEventRepository(dbClient, readReplicaDbClient) - const userRepository = new UserRepository(dbClient, eventRepository) - - return new StaticMirroringWorker(eventRepository, userRepository, process, createSettings) -} diff --git a/src/factories/subscription-service-factory.ts b/src/factories/subscription-service-factory.ts new file mode 100644 index 000000000..845dc22af --- /dev/null +++ b/src/factories/subscription-service-factory.ts @@ -0,0 +1,8 @@ +import { getMasterDbClient } from '../database/client' +import { UserSubscriptionRepository } from '../repositories/user-subscription-repository' +import { SubscriptionService } from '../services/subscription-service' +import { createPaymentsService } from './payments-service-factory' +import { createSettings } from './settings-factory' + +export const createSubscriptionService = () => + new SubscriptionService(new UserSubscriptionRepository(getMasterDbClient()), createPaymentsService(), createSettings) diff --git a/src/handlers/auth-message-handler.ts b/src/handlers/auth-message-handler.ts index 538f412ff..88241007f 100644 --- a/src/handlers/auth-message-handler.ts +++ b/src/handlers/auth-message-handler.ts @@ -7,6 +7,7 @@ import { IMessageHandler } from '../@types/message-handlers' import { IWebSocketAdapter } from '../@types/adapters' import { Settings } from '../@types/settings' import { WebSocketAdapterEvent } from '../constants/adapter' +import { isConfiguredPeeringPeer } from '../utils/peering-auth' const logger = createLogger('auth-message-handler') @@ -66,6 +67,13 @@ export class AuthMessageHandler implements IMessageHandler { this.sendResult(event.id, false, 'invalid: auth event already used') return } + + // Peer identity is a verified AUTH from a configured pubkey, never a URL + // or a self-declared key. Unconfigured pubkeys stay ordinary clients. + if (isConfiguredPeeringPeer(this.settings(), event.pubkey)) { + this.webSocket.setPeerPubkey(event.pubkey) + } + this.sendResult(event.id, true, '') } diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index d90f248cf..941892c54 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -15,6 +15,7 @@ import { verifyNip05Identifier, } from '../utils/nip05' import { Event, ExpiringEvent } from '../@types/event' +import { UserSubscription } from '../@types/user-subscription' import { EventRateLimit, FeeSchedule, Settings } from '../@types/settings' import { getEventExpiration, @@ -33,20 +34,52 @@ import { isSealEvent, isWelcomeRumorEvent, } from '../utils/event' +import { + areSubscriptionsEnabled, + findSubscriptionPlan, + getAccessEndsAt, + getSubscriptionPlans, + hasSubscriptionAccess, +} from '../utils/subscription' import { isAuthRequired } from '../utils/nip42' -import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' +import { + IEventRepository, + INip05VerificationRepository, + IUserRepository, + IUserSubscriptionRepository, +} from '../@types/repositories' import { IEventStrategy, IMessageHandler } from '../@types/message-handlers' -import { admissionCacheKey, CacheAdmissionState } from '../constants/caching' +import { + admissionCacheKey, + CacheAdmissionState, + NO_SUBSCRIPTION_PLAN, + SUBSCRIPTION_PLAN_PREFIX, + subscriptionPlanCacheKey, +} from '../constants/caching' import { createEventCommandResult } from '../telemetry/event-metrics' import { createLogger } from '../factories/logger-factory' import { Factory } from '../@types/base' import { ICacheAdapter } from '../@types/adapters' import { IncomingEventMessage } from '../@types/messages' -import { IRateLimiter } from '../@types/utils' +import { IRateLimiter, IRateLimiterResult } from '../@types/utils' import { IWebSocketAdapter } from '../@types/adapters' import { IWotGraphService } from '../@types/services' import { Nip05Verification } from '../@types/nip05' import { WebSocketAdapterEvent } from '../constants/adapter' +import { recordModerationRateLimitHitSafely } from '../utils/moderation-rate-limit-stats' + +/** + * Why an event was admitted or refused, in the terms the wire needs. + * + * `limited` always carries a backoff — the strategy's estimate when it has one, + * the period when it does not. `unavailable` is our fault, not the client's, and + * must not be announced as `rate-limited`: every client would back off for a + * backend outage. + */ +type RateLimitVerdict = + | { status: 'allowed' } + | { status: 'limited', retryAfterMs: number } + | { status: 'unavailable' } const logger = createLogger('event-message-handler') @@ -61,8 +94,11 @@ export class EventMessageHandler implements IMessageHandler { private readonly cache: ICacheAdapter, private readonly rateLimiter: Factory, private readonly wotGraphService: IWotGraphService, + private readonly userSubscriptionRepository?: IUserSubscriptionRepository, ) {} + private readonly subscriptionReads = new WeakMap>() + public async handleMessage(message: IncomingEventMessage): Promise { let [, event] = message @@ -83,13 +119,27 @@ export class EventMessageHandler implements IMessageHandler { event = this.addExpirationMetadata(event) - const rateLimited = await this.isRateLimited(event) - if (rateLimited) { + const rateLimit = await this.isRateLimited(event) + if (rateLimit.status === 'limited') { logger('event %s rejected: rate-limited', event.id) - const retryAfterMs = typeof rateLimited === 'number' ? String(rateLimited) : undefined + void recordModerationRateLimitHitSafely(this.cache, { + pubkey: event.pubkey, + ip: event[ContextMetadataKey]?.remoteAddress?.address, + }) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createEventCommandResult(event.id, false, 'rate-limited: slow down', String(rateLimit.retryAfterMs)), + ) + return + } + + if (rateLimit.status === 'unavailable') { + // Not the client's fault, so it gets `error:` and no backoff: telling it to + // slow down would make every client back off at once for our outage. + logger('event %s rejected: rate limiter unavailable', event.id) this.webSocket.emit( WebSocketAdapterEvent.Message, - createEventCommandResult(event.id, false, 'rate-limited: slow down', retryAfterMs), + createEventCommandResult(event.id, false, 'error: rate limiter unavailable'), ) return } @@ -163,7 +213,10 @@ export class EventMessageHandler implements IMessageHandler { this.processNip05Metadata(event) } catch (error) { logger.error('error handling message', message, error) - this.webSocket.emit(WebSocketAdapterEvent.Message, createEventCommandResult(event.id, false, 'error: unable to process event')) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createEventCommandResult(event.id, false, 'error: unable to process event'), + ) } } @@ -374,17 +427,26 @@ export class EventMessageHandler implements IMessageHandler { } /** - * Resolves to false when allowed; otherwise to the tripped window's period in ms, - * sent as the NIP-01 `rate-limited` backoff, or true when no backoff can be given. + * Why an event was not admitted, in the terms the wire needs. + * + * `limited` carries the backoff when the strategy can estimate one, and the + * caller sends the period when it cannot. `unavailable` is a different thing: + * the limiter backend failed, so the event is rejected fail-closed, but the + * client must not be told to slow down — every client would back off at once + * for a fault that is ours. */ - protected async isRateLimited(event: Event): Promise { + protected async isRateLimited(event: Event): Promise { if (this.getRelayPublicKey() === event.pubkey) { - return false + return { status: 'allowed' } } - const { whitelists, rateLimits } = this.settings().limits?.event ?? {} + const currentSettings = this.settings() + const { whitelists } = currentSettings.limits?.event ?? {} + // A subscriber's plan can replace the global limits, which carry on for everyone else. + const rateLimits = + (await this.getPlanRateLimits(event, currentSettings)) ?? currentSettings.limits?.event?.rateLimits if (!rateLimits || !rateLimits.length) { - return false + return { status: 'allowed' } } if ( @@ -392,7 +454,7 @@ export class EventMessageHandler implements IMessageHandler { Array.isArray(whitelists?.pubkeys) && whitelists.pubkeys.includes(event.pubkey) ) { - return false + return { status: 'allowed' } } if ( @@ -400,7 +462,7 @@ export class EventMessageHandler implements IMessageHandler { Array.isArray(whitelists?.ipAddresses) && whitelists.ipAddresses.includes(this.webSocket.getClientAddress()) ) { - return false + return { status: 'allowed' } } const rateLimiter = this.rateLimiter() @@ -423,23 +485,107 @@ export class EventMessageHandler implements IMessageHandler { continue } - let isRateLimited = false + let result: IRateLimiterResult try { - isRateLimited = await hit({ period, rate, kinds }) + result = await hit({ period, rate, kinds }) } catch (error) { - // Fail closed when the rate limiter backend is unavailable. + // Fail closed when the rate limiter backend is unavailable, but say so: + // announcing `rate-limited` here made every connected client back off + // for a fault on this side. logger('rate limiter unavailable for %s (%d/%d): %o', event.pubkey, rate, period, error) - return true + return { status: 'unavailable' } } - if (isRateLimited) { + if (result.limited) { logger('rate limited %s: %d events / %d ms exceeded', event.pubkey, rate, period) - return Math.max(1, Math.ceil(period)) + return { + status: 'limited', + retryAfterMs: result.retryAfterMs ?? Math.max(1, Math.ceil(period)), + } + } + } + + return { status: 'allowed' } + } + + /** The rate limits of the plan the sender is subscribed to, if it has its own and the subscription still gives access. */ + private async getPlanRateLimits(event: Event, settings: Settings): Promise { + if (!areSubscriptionsEnabled(settings) || !this.userSubscriptionRepository) { + return undefined + } + + if (!getSubscriptionPlans(settings).some((plan) => plan.limits?.rateLimits !== undefined)) { + return undefined + } + + return findSubscriptionPlan(settings, await this.getActivePlanId(event))?.limits?.rateLimits + } + + /** Cached like admission, so it is read from the database once per pubkey and not once per event. */ + private async getActivePlanId(event: Event): Promise { + const pubkey = event.pubkey + const cacheKey = subscriptionPlanCacheKey(pubkey) + + try { + const cached = await this.cache.getKey(cacheKey) + if (cached === NO_SUBSCRIPTION_PLAN) { + return undefined + } + if (typeof cached === 'string' && cached.startsWith(SUBSCRIPTION_PLAN_PREFIX)) { + return cached.slice(SUBSCRIPTION_PLAN_PREFIX.length) } + } catch (error) { + logger('cache error for %s subscription plan: %o', pubkey, error) + } + + let subscription + try { + subscription = await this.readSubscription(event) + } catch (error) { + // Without the subscription the global limits apply, and nothing is cached so the next event asks again. + logger('unable to read the subscription of %s: %o', pubkey, error) + return undefined + } + + const now = new Date() + const planId = subscription && hasSubscriptionAccess(subscription, now) ? subscription.planId : undefined + this.cacheSubscriptionPlan(pubkey, planId, subscription ? getAccessEndsAt(subscription) : undefined, now) + + return planId + } + + private cacheSubscriptionPlan(pubkey: string, planId: string | undefined, accessEndsAt: Date | undefined, now: Date) { + // Only a plan that is being cached ends with access; "no plan" is rechecked after a minute whatever the end was. + const ttl = planId ? this.cacheTtlUntil(300, accessEndsAt, now) : 60 + this.cacheSet( + subscriptionPlanCacheKey(pubkey), + planId ? `${SUBSCRIPTION_PLAN_PREFIX}${planId}` : NO_SUBSCRIPTION_PLAN, + ttl, + ) + } + + /** + * The subscription of the sender, read once per message: the rate limits and the admission check both need it, + * and on a cold cache they would otherwise each ask the database for the same row. + */ + private readSubscription(event: Event): Promise { + let read = this.subscriptionReads.get(event) + if (!read) { + read = (this.userSubscriptionRepository as IUserSubscriptionRepository).findByPubkey(event.pubkey) + this.subscriptionReads.set(event, read) } - return false + return read + } + + /** No longer than access lasts, so a plan's limits end when its period does rather than minutes later. */ + private cacheTtlUntil(ttl: number, accessEndsAt: Date | undefined, now: Date): number { + if (!accessEndsAt) { + return ttl + } + + return Math.max(1, Math.min(ttl, Math.ceil((accessEndsAt.getTime() - now.getTime()) / 1000))) } protected async isUserAdmitted(event: Event): Promise { @@ -477,7 +623,11 @@ export class EventMessageHandler implements IMessageHandler { const feeSchedules = paymentsEnabled ? currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee) : undefined - const admissionFeeRequired = Array.isArray(feeSchedules) && feeSchedules.length > 0 + // With plans and no admission fee at all, a plan is what admits a user. + const subscriptionsEnabled = areSubscriptionsEnabled(currentSettings) + const onlyPlansAdmit = + subscriptionsEnabled && !currentSettings.payments?.feeSchedules?.admission?.some((schedule) => schedule.enabled) + const admissionFeeRequired = (Array.isArray(feeSchedules) && feeSchedules.length > 0) || onlyPlansAdmit if (!admissionFeeRequired && !nip43Enabled) { return } @@ -512,13 +662,29 @@ export class EventMessageHandler implements IMessageHandler { return notAdmittedReason } - const minBalance = paymentsEnabled ? currentSettings.limits?.event?.pubkey?.minBalance ?? 0n : 0n + const minBalance = paymentsEnabled ? (currentSettings.limits?.event?.pubkey?.minBalance ?? 0n) : 0n if (minBalance > 0n && user.balance < minBalance) { this.cacheSet(cacheKey, CacheAdmissionState.BLOCKED_INSUFFICIENT_BALANCE, 60) return 'blocked: insufficient balance' } - this.cacheSet(cacheKey, CacheAdmissionState.ADMITTED, 300) + let admittedTtl = 300 + if (subscriptionsEnabled && this.userSubscriptionRepository) { + const subscription = await this.readSubscription(event) + const now = new Date() + if (!hasSubscriptionAccess(subscription, now)) { + this.cacheSet(cacheKey, CacheAdmissionState.BLOCKED_NOT_ADMITTED, 60) + return 'blocked: subscription expired' + } + + // Cache no longer than access lasts, so the block lands when the period ends rather than minutes later. + const accessEndsAt = subscription ? getAccessEndsAt(subscription) : undefined + admittedTtl = this.cacheTtlUntil(admittedTtl, accessEndsAt, now) + // The plan was just read, so what the rate limits need is cached with it. + this.cacheSubscriptionPlan(event.pubkey, subscription?.planId, accessEndsAt, now) + } + + this.cacheSet(cacheKey, CacheAdmissionState.ADMITTED, admittedTtl) } private cacheSet(key: string, value: string, ttl: number): void { diff --git a/src/handlers/neg-close-message-handler.ts b/src/handlers/neg-close-message-handler.ts new file mode 100644 index 000000000..e4ef1f314 --- /dev/null +++ b/src/handlers/neg-close-message-handler.ts @@ -0,0 +1,12 @@ +import { IMessageHandler } from '../@types/message-handlers' +import { IWebSocketAdapter } from '../@types/adapters' +import { NegCloseMessage } from '../@types/messages' + +export class NegCloseMessageHandler implements IMessageHandler { + public constructor(private readonly webSocket: IWebSocketAdapter) {} + + // NIP-77 defines no reply to NEG-CLOSE, and closing an unknown id is harmless. + public async handleMessage(message: NegCloseMessage): Promise { + this.webSocket.getNegentropySessions().close(message[1]) + } +} diff --git a/src/handlers/neg-msg-message-handler.ts b/src/handlers/neg-msg-message-handler.ts new file mode 100644 index 000000000..158536240 --- /dev/null +++ b/src/handlers/neg-msg-message-handler.ts @@ -0,0 +1,60 @@ +import { createNegErrMessage, createNegMsgMessage } from '../utils/messages' +import { IMessageHandler } from '../@types/message-handlers' +import { IWebSocketAdapter } from '../@types/adapters' +import { NegMsgMessage, OutgoingMessage } from '../@types/messages' +import { Settings } from '../@types/settings' +import { WebSocketAdapterEvent } from '../constants/adapter' +import { createLogger } from '../factories/logger-factory' +import { isNip77Active } from '../utils/nip77' +import { isReconciliationAuthorized } from '../utils/nip42' + +const logger = createLogger('neg-msg-message-handler') + +export class NegMsgMessageHandler implements IMessageHandler { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly settings: () => Settings, + ) {} + + public async handleMessage(message: NegMsgMessage): Promise { + const subscriptionId = message[1] + const sessions = this.webSocket.getNegentropySessions() + + if (!isNip77Active(this.settings())) { + // Turned off while a session was open: end it instead of leaving it half-served. + sessions.close(subscriptionId) + this.reply(createNegErrMessage(subscriptionId, 'blocked: NIP-77 is disabled by relay configuration')) + return + } + + const session = sessions.touch(subscriptionId) + if (!session) { + this.reply(createNegErrMessage(subscriptionId, 'closed: unknown negentropy subscription')) + return + } + + // Access can lapse while a session is open (e.g. NIP-42 auth expiring), so check it every time. + if (!isReconciliationAuthorized(this.settings(), session.filter, () => this.webSocket.getAuthenticatedPubkeys())) { + sessions.close(subscriptionId) + this.reply( + createNegErrMessage(subscriptionId, 'auth-required: authentication is required to sync these event kinds'), + ) + return + } + + try { + const { output } = session.negentropy.reconcile(Buffer.from(message[2], 'hex')) + + this.reply(createNegMsgMessage(subscriptionId, Buffer.from(output as Uint8Array).toString('hex'))) + } catch (error) { + // NIP-77: after a NEG-ERR the subscription is closed. + sessions.close(subscriptionId) + logger('NEG-MSG %s sent a malformed negentropy message: %o', subscriptionId, error) + this.reply(createNegErrMessage(subscriptionId, 'invalid: malformed negentropy message')) + } + } + + private reply(message: OutgoingMessage): void { + this.webSocket.emit(WebSocketAdapterEvent.Message, message) + } +} diff --git a/src/handlers/neg-open-message-handler.ts b/src/handlers/neg-open-message-handler.ts new file mode 100644 index 000000000..03c0e7ce4 --- /dev/null +++ b/src/handlers/neg-open-message-handler.ts @@ -0,0 +1,160 @@ +import { IWebSocketAdapter } from '../@types/adapters' +import { createNegErrMessage, createNegMsgMessage } from '../utils/messages' +import { getNip77Limits, isNip77Active, NEGENTROPY_FRAME_SIZE_LIMIT } from '../utils/nip77' +import { INegentropySessions } from '../@types/negentropy' +import { Negentropy, NegentropyStorageVector } from '../utils/negentropy' +import { IEventRepository, ReconciliationItem } from '../@types/repositories' +import { IMessageHandler } from '../@types/message-handlers' +import { NegOpenMessage, OutgoingMessage } from '../@types/messages' +import { Settings } from '../@types/settings' +import { SubscriptionFilter, SubscriptionId } from '../@types/subscription' +import { WebSocketAdapterEvent } from '../constants/adapter' +import { countFilterValues } from '../utils/filter' +import { createLogger } from '../factories/logger-factory' +import { isReconciliationAuthorized } from '../utils/nip42' + +const logger = createLogger('neg-open-message-handler') + +// Negentropy protocol versions are 0x60-0x6f; anything else is not a negentropy message at all. +const isNegentropyVersionByte = (byte: number | undefined): boolean => + byte !== undefined && byte >= 0x60 && byte <= 0x6f + +export class NegOpenMessageHandler implements IMessageHandler { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly eventRepository: IEventRepository, + private readonly settings: () => Settings, + ) {} + + public async handleMessage(message: NegOpenMessage): Promise { + const subscriptionId = message[1] + const filter = message[2] + const initialMessage = Buffer.from(message[3], 'hex') + + const settings = this.settings() + const sessions = this.webSocket.getNegentropySessions() + + if (!isNip77Active(settings)) { + this.reject(sessions, subscriptionId, 'blocked: NIP-77 is disabled by relay configuration') + return + } + + const rejection = this.getRejection(subscriptionId, filter, initialMessage) + if (rejection) { + logger('NEG-OPEN %s with %o rejected: %s', subscriptionId, filter, rejection) + this.reject(sessions, subscriptionId, rejection) + return + } + + const { maxEventsPerSession, maxSessionsPerConnection, sessionIdleTimeoutMs } = getNip77Limits(settings) + + const token = sessions.reserve(subscriptionId, maxSessionsPerConnection) + if (!token) { + this.reject( + sessions, + subscriptionId, + `blocked: too many negentropy sessions or reads in progress on this connection (max ${maxSessionsPerConnection})`, + ) + return + } + + let items: ReconciliationItem[] + try { + // One row past the limit tells us the set is too big without loading all of it. + items = await this.eventRepository.findReconciliationItems(filter, maxEventsPerSession + 1) + } catch (error) { + if (sessions.isCurrent(subscriptionId, token)) { + sessions.release(subscriptionId, token) + logger('NEG-OPEN %s failed: %o', subscriptionId, error) + // Keep this generic so internal errors are not leaked to clients. + this.reply(createNegErrMessage(subscriptionId, 'error: unable to process negentropy request')) + } + return + } finally { + sessions.endRead(token) + } + + // The client may have closed or replaced this NEG-OPEN while events were being read. Then the + // newer request owns the id: say nothing, since an error here would tell the client to drop it. + if (!sessions.isCurrent(subscriptionId, token)) { + return + } + + // Nothing below awaits, so ownership cannot change before the reply. + if (items.length > maxEventsPerSession) { + sessions.release(subscriptionId, token) + this.reply(createNegErrMessage(subscriptionId, 'blocked: this query is too big', maxEventsPerSession)) + return + } + + try { + const storage = new NegentropyStorageVector() + for (const item of items) { + storage.insert(item.event_created_at, item.event_id) + } + storage.seal() + + const negentropy = new Negentropy(storage, NEGENTROPY_FRAME_SIZE_LIMIT) + + let output: Uint8Array + try { + output = negentropy.reconcile(initialMessage).output as Uint8Array + } catch (error) { + // The only input from the client here is the initial message, so this is its fault. + sessions.release(subscriptionId, token) + logger('NEG-OPEN %s sent a malformed negentropy message: %o', subscriptionId, error) + this.reply(createNegErrMessage(subscriptionId, 'invalid: malformed negentropy message')) + return + } + + sessions.activate(subscriptionId, token, { negentropy, filter }, sessionIdleTimeoutMs) + this.reply(createNegMsgMessage(subscriptionId, Buffer.from(output).toString('hex'))) + } catch (error) { + sessions.release(subscriptionId, token) + logger('NEG-OPEN %s failed: %o', subscriptionId, error) + this.reply(createNegErrMessage(subscriptionId, 'error: unable to process negentropy request')) + } + } + + private getRejection( + subscriptionId: SubscriptionId, + filter: SubscriptionFilter, + initialMessage: Buffer, + ): string | undefined { + const settings = this.settings() + const subscriptionLimits = settings.limits?.client?.subscription + + const maxFilterValues = subscriptionLimits?.maxFilterValues ?? 0 + if (maxFilterValues > 0 && countFilterValues(filter) > maxFilterValues) { + return `blocked: too many filter values (max ${maxFilterValues})` + } + + if ( + typeof subscriptionLimits?.maxSubscriptionIdLength === 'number' && + subscriptionId.length > subscriptionLimits.maxSubscriptionIdLength + ) { + return `invalid: subscription id too long (max ${subscriptionLimits.maxSubscriptionIdLength})` + } + + if (!isNegentropyVersionByte(initialMessage[0])) { + return 'invalid: malformed negentropy message' + } + + // NIP-42: reconciling reveals the id of every event the filter matches, so a filter that could + // match a restricted kind must be scoped to the client's own pubkeys. + if (!isReconciliationAuthorized(settings, filter, () => this.webSocket.getAuthenticatedPubkeys())) { + return 'auth-required: authentication is required to sync these event kinds' + } + } + + // NIP-77: after a NEG-ERR the subscription is closed, so a rejected NEG-OPEN must also end any + // session the client already had under that id; otherwise it keeps serving, and holding a slot. + private reject(sessions: INegentropySessions, subscriptionId: SubscriptionId, reason: string): void { + sessions.close(subscriptionId) + this.reply(createNegErrMessage(subscriptionId, reason)) + } + + private reply(message: OutgoingMessage): void { + this.webSocket.emit(WebSocketAdapterEvent.Message, message) + } +} diff --git a/src/handlers/request-handlers/rate-limiter-middleware.ts b/src/handlers/request-handlers/rate-limiter-middleware.ts index 24ee74a4e..642bea41e 100644 --- a/src/handlers/request-handlers/rate-limiter-middleware.ts +++ b/src/handlers/request-handlers/rate-limiter-middleware.ts @@ -41,7 +41,7 @@ export async function isRateLimited(remoteAddress: string, settings: Settings): let limited = false for (const { rate, period } of rateLimits) { - const isRateLimited = await hit(period, rate) + const isRateLimited = (await hit(period, rate)).limited if (isRateLimited) { logger('rate limited %s: %d messages / %d ms exceeded', remoteAddress, rate, period) diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 6a5eeaf6f..2be6efa5c 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -2,11 +2,13 @@ import { NextFunction, Request, Response } from 'express' import { path, pathEq } from 'ramda' import { createSettings } from '../../factories/settings-factory' import { escapeHtml } from '../../utils/html' -import { FeeSchedule } from '../../@types/settings' +import { FeeSchedule, PeeringSettings } from '../../@types/settings' import { DEFAULT_FILTER_LIMIT } from '../../constants/base' import { fromBech32 } from '../../utils/transform' import { getTemplate } from '../../utils/template-cache' import { getPublicPathPrefix, joinPathPrefix } from '../../utils/http' +import { areSubscriptionsEnabled, getSubscriptionPlans } from '../../utils/subscription' +import { isNip77Active } from '../../utils/nip77' import { resolveRelaySelfPubkey } from '../../utils/nip43' import packageJson from '../../../package.json' @@ -40,6 +42,35 @@ export const hasExplicitNostrJsonAcceptHeader = (request: Request): boolean => { }) } +export const buildPeeringDocument = (peering: PeeringSettings | undefined) => { + // Disabled peering advertises only the fact that no feeds are offered, so a + // half-configured section never reads as an offer. + if (peering?.enabled !== true) { + return { enabled: false } + } + + const contact = peering.contact?.trim() + const endpoint = peering.endpoint?.trim() + + return { + enabled: true, + ...(contact ? { contact } : {}), + ...(endpoint ? { endpoint } : {}), + // NIP-11 convention: snake_case in the document, and 0/absent both mean + // no proof-of-work requirement. + min_pow_difficulty: peering.minRequestPowDifficulty ?? 0, + scopes: (peering.scopes ?? []).map((scope) => ({ + kinds: scope.kinds, + ...(scope.since !== undefined ? { since: scope.since } : {}), + ...(scope.until !== undefined ? { until: scope.until } : {}), + ...(scope.windowSeconds !== undefined ? { window_seconds: scope.windowSeconds } : {}), + })), + // An empty list is the settlement-free case: terms are expressed as fee + // entries, so "no fee entry" is meaningful rather than a zero amount. + terms: (peering.terms ?? []).map(({ amount, unit, period }) => ({ amount, unit, period })), + } +} + export const rootRequestHandler = (request: Request, response: Response, next: NextFunction) => { const settings = createSettings() const pathPrefix = getPublicPathPrefix(request, settings) @@ -59,8 +90,11 @@ export const rootRequestHandler = (request: Request, response: Response, next: N const hasAdmissionRestriction = settings.payments?.enabled === true && Boolean(settings.payments?.feeSchedules?.admission?.some((feeSchedule) => feeSchedule.enabled)) + const subscriptionsEnabled = areSubscriptionsEnabled(settings) const hasWriteRestriction = hasAdmissionRestriction || + // With plans a subscription is what lets a pubkey write, whether or not an admission fee is set. + subscriptionsEnabled || settings.nip43?.enabled === true || // Publish-only NIP-42 auth is a write condition, not connection-wide auth_required. settings.nip42?.authRequired === true || @@ -88,10 +122,11 @@ export const rootRequestHandler = (request: Request, response: Response, next: N ...(self !== undefined ? { self } : {}), contact, // NIP-43: clients MUST only send join requests to relays advertising 43, - // so only advertise it when the feature is actually enabled - supported_nips: settings.nip43?.enabled === true - ? packageJson.supportedNips - : packageJson.supportedNips.filter((nip: number) => nip !== 43), + // so only advertise it when the feature is actually enabled. Likewise 77 + // only while this relay answers NEG-OPEN itself (enabled, PostgreSQL backend). + supported_nips: packageJson.supportedNips.filter( + (nip: number) => (nip !== 43 || settings.nip43?.enabled === true) && (nip !== 77 || isNip77Active(settings)), + ), supported_nip_extensions: packageJson.supportedNipExtensions, supported_mips: packageJson.supportedMips, software: packageJson.repository.url, @@ -140,22 +175,38 @@ export const rootRequestHandler = (request: Request, response: Response, next: N search_supported: settings.nip50?.enabled ?? false, }, payments_url: paymentsUrl.toString(), - fees: settings.payments - ? Object.getOwnPropertyNames(settings.payments.feeSchedules).reduce( - (prev, feeName) => { - const feeSchedules = settings.payments.feeSchedules[feeName] as FeeSchedule[] - - return { - ...prev, - [feeName]: feeSchedules.reduce( - (fees, fee) => (fee.enabled ? [...fees, { amount: fee.amount, unit: 'msats' }] : fees), - [], - ), - } - }, - {} as Record, - ) - : {}, + fees: { + ...(settings.payments + ? Object.getOwnPropertyNames(settings.payments.feeSchedules).reduce( + (prev, feeName) => { + const feeSchedules = settings.payments.feeSchedules[feeName] as FeeSchedule[] + + return { + ...prev, + [feeName]: feeSchedules.reduce( + (fees, fee) => (fee.enabled ? [...fees, { amount: fee.amount, unit: 'msats' }] : fees), + [], + ), + } + }, + {} as Record, + ) + : {}), + // NIP-11 `fees.subscription`: one entry per plan, `period` in seconds. `id` is what a client sends as + // `planId` to `POST /invoices`; clients that do not know it ignore it. + ...(subscriptionsEnabled + ? { + subscription: getSubscriptionPlans(settings).map((plan) => ({ + id: plan.id, + amount: Number(plan.amount), + unit: 'msats', + period: plan.intervalSeconds, + ...(plan.description ? { description: plan.description } : {}), + })), + } + : {}), + }, + peering: buildPeeringDocument(settings.peering), } response diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index f79621e95..ef5667fb9 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -188,7 +188,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { for (const { period, rate } of rateLimits) { let isRateLimited: boolean try { - isRateLimited = await rateLimiter.hit(`${pubkey}:invites:${period}`, 1, { period, rate }) + isRateLimited = (await rateLimiter.hit(`${pubkey}:invites:${period}`, 1, { period, rate })).limited } catch (error) { // Fail closed: minting writes to the database, so an unavailable limiter // must not leave the gate open. diff --git a/src/index.ts b/src/index.ts index a1f9d1a99..b909447ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,6 @@ import { appFactory } from './factories/app-factory' import { dvmOrchestratorWorkerFactory } from './factories/dvm-orchestrator-worker-factory' import { maintenanceWorkerFactory } from './factories/maintenance-worker-factory' import { relayMonitorWorkerFactory } from './factories/relay-monitor-worker-factory' -import { staticMirroringWorkerFactory } from './factories/static-mirroring.worker-factory' import { workerFactory } from './factories/worker-factory' import { initializeMetricsTelemetry } from './telemetry/metrics' @@ -17,8 +16,6 @@ export const getRunner = () => { return workerFactory() case 'maintenance': return maintenanceWorkerFactory() - case 'static-mirroring': - return staticMirroringWorkerFactory() case 'relay-monitor': return relayMonitorWorkerFactory() case 'dvm-orchestrator': diff --git a/src/payments-processors/lnbits-payment-processor.ts b/src/payments-processors/lnbits-payment-processor.ts index f14d2af7d..cf97d2a89 100644 --- a/src/payments-processors/lnbits-payment-processor.ts +++ b/src/payments-processors/lnbits-payment-processor.ts @@ -11,6 +11,10 @@ import { Settings } from '../@types/settings' const logger = createLogger('lnbits-payments-processor') +// LNbits < 1.0 returns unix seconds for details.time/details.expiry, +// LNbits >= 1.0 returns ISO 8601 strings ("2025-10-11T15:24:27+00:00"). +const toDate = (value: number | string): Date => (typeof value === 'number' ? new Date(value * 1000) : new Date(value)) + export class LNbitsInvoice implements Invoice { id: string pubkey: Pubkey @@ -62,7 +66,7 @@ export class LNbitsPaymentsProcessor implements IPaymentsProcessor { invoice.amountPaid = BigInt(Math.floor(data.details.amount / 1000)) } invoice.unit = InvoiceUnit.SATS - invoice.expiresAt = new Date(data.details.expiry * 1000) + invoice.expiresAt = toDate(data.details.expiry) if (data.paid) { invoice.status = InvoiceStatus.COMPLETED } else if (isExpiredInvoice(invoice)) { @@ -71,8 +75,8 @@ export class LNbitsPaymentsProcessor implements IPaymentsProcessor { invoice.status = InvoiceStatus.PENDING } invoice.description = data.details.memo - invoice.confirmedAt = data.paid ? new Date(data.details.time * 1000) : null - invoice.createdAt = new Date(data.details.time * 1000) + invoice.confirmedAt = data.paid ? toDate(data.details.time) : null + invoice.createdAt = toDate(data.details.time) invoice.updatedAt = new Date() return invoice } catch (error) { @@ -129,8 +133,8 @@ export class LNbitsPaymentsProcessor implements IPaymentsProcessor { invoice.status = data.paid ? InvoiceStatus.COMPLETED : InvoiceStatus.PENDING invoice.description = data.details.memo invoice.confirmedAt = null - invoice.expiresAt = new Date(data.details.expiry * 1000) - invoice.createdAt = new Date(data.details.time * 1000) + invoice.expiresAt = toDate(data.details.expiry) + invoice.createdAt = toDate(data.details.time) invoice.rawResponse = JSON.stringify({ invoiceResponse: invoiceResponse.data, createData: response.data, diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 3ac583162..fe201d89f 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -101,7 +101,10 @@ export class EventRepository implements IEventRepository { return query } - public async countByFilters(filters: SubscriptionFilter[]): Promise { + public async countByFilters( + filters: SubscriptionFilter[], + options?: { timeoutMs?: number }, + ): Promise { logger('counting events for %o', filters) if (!Array.isArray(filters) || !filters.length) { @@ -129,7 +132,11 @@ export class EventRepository implements IEventRepository { const query = queries.length === 1 ? queries[0] : this.unionFilterQueries(queries) // Rows are already unique: EXISTS never repeats an event and UNION drops overlaps between filters. - const result = await this.readReplicaDbClient.from(query.as('matching_events')).count({ count: '*' }).first() + const countQuery = this.readReplicaDbClient.from(query.as('matching_events')).count({ count: '*' }) + if (typeof options?.timeoutMs === 'number') { + countQuery.timeout(options.timeoutMs, { cancel: true }) + } + const result = await countQuery.first() return Number(result?.count ?? 0) } @@ -138,9 +145,10 @@ export class EventRepository implements IEventRepository { * Every live event matching the filter as (created_at, id), oldest first, for NIP-77 set * reconciliation. The filter's limit is ignored on purpose: reconciliation compares whole sets, so * truncating one side would report the cut-off events as missing. Deleted and expired events are - * excluded because they must never be advertised to a peer. + * excluded because they must never be advertised to a peer. `limit` is a safety cap for callers + * that refuse oversized sets (they ask for one more row than they accept); it is never a window. */ - public findReconciliationItems(filter: SubscriptionFilter): IQueryResult { + public findReconciliationItems(filter: SubscriptionFilter, limit?: number): IQueryResult { logger('querying reconciliation items for %o', filter) const now = Math.floor(Date.now() / 1000) @@ -156,6 +164,10 @@ export class EventRepository implements IEventRepository { .orderBy('events.event_created_at', 'asc') .orderBy('events.event_id', 'asc') + if (typeof limit === 'number') { + builder.limit(limit) + } + return builder as unknown as IQueryResult } @@ -527,6 +539,11 @@ export class EventRepository implements IEventRepository { } const retentionLimit = now - maxDays * SECONDS_PER_DAY + // Anything the retention rule purges is older than the latest of the limits, which is what the counts use. + const countLimit = Math.max( + retentionLimit, + ...this.getPlanRetention(options).map((plan) => now - plan.maxDays * SECONDS_PER_DAY), + ) logger( 'deleting expired and retained events (retentionLimit: %d, now: %d, batchSize: %d)', @@ -541,7 +558,7 @@ export class EventRepository implements IEventRepository { .whereIn('event_id', candidates) .del(['deleted_at', 'expires_at', 'event_created_at']) - const getPromise = () => query.then((rows: any) => this.mapToPurgeCounts(rows, now, retentionLimit)) + const getPromise = () => query.then((rows: any) => this.mapToPurgeCounts(rows, now, countLimit)) return { then: ( @@ -559,10 +576,41 @@ export class EventRepository implements IEventRepository { retentionLimit: number, options?: EventRetentionOptions, ): any { + const planRetention = this.getPlanRetention(options) + const subscribersOf = (planIds: string[]) => + this.masterDbClient('user_subscriptions') + .select('pubkey') + .whereIn('plan_id', planIds) + .whereNot('status', 'expired') + .where((access) => + access + .whereRaw('COALESCE(grace_ends_at, current_period_end) IS NULL') + .orWhereRaw('COALESCE(grace_ends_at, current_period_end) > ?', [new Date(now * 1000)]), + ) + return this.masterDbClient('events') .select('event_id') - .where(function () { - this.where('expires_at', '<', now).orWhereNotNull('deleted_at').orWhere('event_created_at', '<', retentionLimit) + .where((builder) => { + builder.where('expires_at', '<', now).orWhereNotNull('deleted_at') + + if (!planRetention.length) { + builder.orWhere('event_created_at', '<', retentionLimit) + return + } + + // Subscribers on a plan with its own retention are held to it instead of the global one. + builder.orWhere((global) => + global + .where('event_created_at', '<', retentionLimit) + .whereNotIn('event_pubkey', subscribersOf(planRetention.map(({ planId }) => planId))), + ) + for (const { planId, maxDays } of planRetention) { + builder.orWhere((plan) => + plan + .where('event_created_at', '<', now - maxDays * SECONDS_PER_DAY) + .whereIn('event_pubkey', subscribersOf([planId])), + ) + } }) .modify((query) => { this.applyRetentionKindWhitelist(query, options?.kindWhitelist) @@ -574,6 +622,12 @@ export class EventRepository implements IEventRepository { .limit(RETENTION_BATCH_SIZE) } + private getPlanRetention(options?: EventRetentionOptions): { planId: string; maxDays: number }[] { + return (options?.planRetention ?? []).filter( + ({ maxDays }) => typeof maxDays === 'number' && !isNaN(maxDays) && maxDays > 0, + ) + } + private applyRetentionKindWhitelist(query: any, kindWhitelist?: EventRetentionOptions['kindWhitelist']): void { const seen = new Set() const configuredWhitelist = Array.isArray(kindWhitelist) ? kindWhitelist : [] diff --git a/src/repositories/invoice-repository.ts b/src/repositories/invoice-repository.ts index 4dbcfd81d..36734916e 100644 --- a/src/repositories/invoice-repository.ts +++ b/src/repositories/invoice-repository.ts @@ -92,6 +92,9 @@ export class InvoiceRepository implements IInvoiceRepository { updated_at: always(new Date()), created_at: prop('createdAt'), verify_url: prop('verifyURL'), + plan_id: prop('planId'), + period_start: prop('periodStart'), + period_end: prop('periodEnd'), })(invoice) logger('row: %o', row) @@ -110,6 +113,9 @@ export class InvoiceRepository implements IInvoiceRepository { 'expires_at', 'created_at', 'verify_url', + 'plan_id', + 'period_start', + 'period_end', ])(row), ) diff --git a/src/repositories/nip05-verification-repository.ts b/src/repositories/nip05-verification-repository.ts index bf2665589..bafa2d92e 100644 --- a/src/repositories/nip05-verification-repository.ts +++ b/src/repositories/nip05-verification-repository.ts @@ -4,7 +4,7 @@ import { DatabaseClient, Pubkey } from '../@types/base' import { DBNip05Verification, Nip05Verification } from '../@types/nip05' import { fromBuffer, toBuffer } from '../utils/transform' import { createLogger } from '../factories/logger-factory' -import { INip05VerificationRepository } from '../@types/repositories' +import { INip05VerificationRepository, Nip05VerificationAdminListQuery } from '../@types/repositories' const logger = createLogger('nip05-verification-repository') @@ -97,4 +97,44 @@ export class Nip05VerificationRepository implements INip05VerificationRepository return this.dbClient('nip05_verifications').where('pubkey', toBuffer(pubkey)).delete() } + + public async listForAdmin(query: Nip05VerificationAdminListQuery): Promise<{ rows: Nip05Verification[]; total: number }> { + let builder = this.dbClient('nip05_verifications') + + if (query.domain) { + builder = builder.where('domain', query.domain) + } + + if (query.verified !== undefined) { + builder = builder.where('is_verified', query.verified) + } + + if (query.minFailures !== undefined) { + builder = builder.where('failure_count', '>=', query.minFailures) + } + + if (query.stale !== undefined) { + const cutoff = new Date(Date.now() - query.verifyExpirationMs) + if (query.stale) { + builder = builder.where((clause) => { + clause + .where('is_verified', false) + .orWhereNull('last_verified_at') + .orWhere('last_verified_at', '<', cutoff) + }) + } else { + builder = builder.where('is_verified', true).andWhere('last_verified_at', '>=', cutoff) + } + } + + const countRow = await builder.clone().count<{ count: string }>({ count: '*' }).first() + const total = Number(countRow?.count ?? 0) + + const rows = await builder + .orderBy('last_checked_at', 'desc') + .limit(query.limit) + .offset(query.offset) + + return { rows: rows.map(fromDBNip05Verification), total } + } } diff --git a/src/repositories/user-subscription-repository.ts b/src/repositories/user-subscription-repository.ts new file mode 100644 index 000000000..3fc570ca2 --- /dev/null +++ b/src/repositories/user-subscription-repository.ts @@ -0,0 +1,162 @@ +import { DatabaseClient, Pubkey } from '../@types/base' +import { DBUserSubscription, SubscriptionStatus, UserSubscription } from '../@types/user-subscription' +import { fromBuffer, toBuffer } from '../utils/transform' +import { InvoiceStatus } from '../@types/invoice' +import { createLogger } from '../factories/logger-factory' +import { IUserSubscriptionRepository } from '../@types/repositories' + +const logger = createLogger('user-subscription-repository') + +const DEFAULT_PAGE_SIZE = 10 + +const fromDBUserSubscription = (row: DBUserSubscription): UserSubscription => ({ + pubkey: fromBuffer(row.pubkey), + planId: row.plan_id, + status: row.status, + currentPeriodStart: row.current_period_start, + currentPeriodEnd: row.current_period_end, + graceEndsAt: row.grace_ends_at, + cancelAtPeriodEnd: row.cancel_at_period_end, + createdAt: row.created_at, + updatedAt: row.updated_at, +}) + +export class UserSubscriptionRepository implements IUserSubscriptionRepository { + public constructor(private readonly dbClient: DatabaseClient) {} + + public async findByPubkey( + pubkey: Pubkey, + client: DatabaseClient = this.dbClient, + ): Promise { + const [row] = await client('user_subscriptions').where('pubkey', toBuffer(pubkey)).select() + + return row ? fromDBUserSubscription(row) : undefined + } + + public async upsert( + subscription: Omit, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('upserting subscription for %s: %s', subscription.pubkey, subscription.planId) + + const now = new Date() + const row: DBUserSubscription = { + pubkey: toBuffer(subscription.pubkey), + plan_id: subscription.planId, + status: subscription.status, + current_period_start: subscription.currentPeriodStart, + current_period_end: subscription.currentPeriodEnd, + grace_ends_at: subscription.graceEndsAt, + cancel_at_period_end: subscription.cancelAtPeriodEnd, + created_at: now, + updated_at: now, + } + + const result = await client('user_subscriptions') + .insert(row) + .onConflict('pubkey') + .merge([ + 'plan_id', + 'status', + 'current_period_start', + 'current_period_end', + 'grace_ends_at', + 'cancel_at_period_end', + 'updated_at', + ]) + + return (result as unknown as { rowCount?: number }).rowCount ?? 0 + } + + public async updateIfCurrent( + expected: UserSubscription, + changes: Partial>, + client: DatabaseClient = this.dbClient, + ): Promise { + const update: Partial = { updated_at: new Date() } + if (changes.status !== undefined) { + update.status = changes.status + } + if (changes.graceEndsAt !== undefined) { + update.grace_ends_at = changes.graceEndsAt + } + + const updated = await client('user_subscriptions') + .where('pubkey', toBuffer(expected.pubkey)) + .where('status', expected.status) + .where('plan_id', expected.planId) + .whereRaw('current_period_end IS NOT DISTINCT FROM ?', [expected.currentPeriodEnd]) + .whereRaw('grace_ends_at IS NOT DISTINCT FROM ?', [expected.graceEndsAt]) + .update(update) + + return updated > 0 + } + + public async findDueForRenewal( + before: Date, + limit = DEFAULT_PAGE_SIZE, + offset = 0, + client: DatabaseClient = this.dbClient, + ): Promise { + const now = new Date() + const rows = await client('user_subscriptions') + .where((builder) => + builder.where('status', SubscriptionStatus.ACTIVE).orWhere((renewalPending) => + renewalPending.where('status', SubscriptionStatus.RENEWAL_PENDING).whereNotExists(function () { + // A renewal invoice that is still payable needs no replacement. + this.select('invoices.id') + .from('invoices') + .whereRaw('invoices.pubkey = user_subscriptions.pubkey') + .whereRaw('invoices.plan_id = user_subscriptions.plan_id') + .where('invoices.status', InvoiceStatus.PENDING) + .where((unexpired) => unexpired.whereNull('invoices.expires_at').orWhere('invoices.expires_at', '>', now)) + }), + ), + ) + .where('cancel_at_period_end', false) + .whereNotNull('current_period_end') + .where('current_period_end', '<=', before) + .orderBy('current_period_end', 'asc') + .limit(limit) + .offset(offset) + .select() + + return rows.map(fromDBUserSubscription) + } + + public async findPeriodEnded( + now: Date, + limit = DEFAULT_PAGE_SIZE, + offset = 0, + client: DatabaseClient = this.dbClient, + ): Promise { + const rows = await client('user_subscriptions') + .whereIn('status', [SubscriptionStatus.ACTIVE, SubscriptionStatus.RENEWAL_PENDING]) + .whereNotNull('current_period_end') + .where('current_period_end', '<=', now) + .orderBy('current_period_end', 'asc') + .limit(limit) + .offset(offset) + .select() + + return rows.map(fromDBUserSubscription) + } + + public async findGraceEnded( + now: Date, + limit = DEFAULT_PAGE_SIZE, + offset = 0, + client: DatabaseClient = this.dbClient, + ): Promise { + const rows = await client('user_subscriptions') + .where('status', SubscriptionStatus.PAST_DUE) + .whereNotNull('grace_ends_at') + .where('grace_ends_at', '<=', now) + .orderBy('grace_ends_at', 'asc') + .limit(limit) + .offset(offset) + .select() + + return rows.map(fromDBUserSubscription) + } +} diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 0f73629f0..8dd5317a5 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -6,6 +6,7 @@ import { createPatchAdminNotificationsController } from '../../factories/control import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory' import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory' import { createGetAdminNetworkHealthController } from '../../factories/controllers/get-admin-network-health-controller-factory' +import { createGetAdminNetworkHealthHistoryController } from '../../factories/controllers/get-admin-network-health-history-controller-factory' import { createGetAdminSessionController } from '../../factories/controllers/get-admin-session-controller-factory' import { createGetAdminSettingsBackupsController } from '../../factories/controllers/get-admin-settings-backups-controller-factory' import { createGetAdminSettingsController } from '../../factories/controllers/get-admin-settings-controller-factory' @@ -16,6 +17,17 @@ import { createPostAdminLoginController } from '../../factories/controllers/post import { createPostAdminLogoutController } from '../../factories/controllers/post-admin-logout-controller-factory' import { createPostAdminSettingsRestoreController } from '../../factories/controllers/post-admin-settings-restore-controller-factory' import { createPostAdminSettingsValidateController } from '../../factories/controllers/post-admin-settings-validate-controller-factory' +import { createGetAdminOpsWorkersController } from '../../factories/controllers/get-admin-ops-workers-controller-factory' +import { createGetAdminOpsNip05VerificationsController } from '../../factories/controllers/get-admin-ops-nip05-verifications-controller-factory' +import { createGetAdminOpsStorageCountsController } from '../../factories/controllers/get-admin-ops-storage-counts-controller-factory' +import { createPostAdminOpsNip05ReverifyController } from '../../factories/controllers/post-admin-ops-nip05-reverify-controller-factory' +import { createGetAdminAuditLogController } from '../../factories/controllers/get-admin-audit-log-controller-factory' +import { createGetAdminDiagnosticsExportController } from '../../factories/controllers/get-admin-diagnostics-export-controller-factory' +import { createPostAdminNetworkHealthRunController } from '../../factories/controllers/post-admin-network-health-run-controller-factory' +import { createGetAdminModerationLimitsController } from '../../factories/controllers/get-admin-moderation-limits-controller-factory' +import { createGetAdminModerationStatsController } from '../../factories/controllers/get-admin-moderation-stats-controller-factory' +import { createPatchAdminModerationLimitsController } from '../../factories/controllers/patch-admin-moderation-limits-controller-factory' +import { createPostAdminModerationActionsController } from '../../factories/controllers/post-admin-moderation-actions-controller-factory' import { adminAuthGateMiddleware, adminAuthMiddleware } from '../../handlers/request-handlers/admin-auth-middleware' import { adminEnabledMiddleware } from '../../handlers/request-handlers/admin-enabled-middleware' import { adminJsonBodyMiddleware } from '../../handlers/request-handlers/admin-json-body-middleware' @@ -67,6 +79,58 @@ router.get( adminAuthMiddleware, withAdminController(createGetAdminNetworkHealthController), ) +router.get( + '/network-health/history', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminNetworkHealthHistoryController), +) +router.post( + '/network-health/run', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createPostAdminNetworkHealthRunController), +) +router.get( + '/audit-log', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminAuditLogController), +) +router.get( + '/diagnostics/export', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminDiagnosticsExportController), +) +router.get( + '/moderation/limits', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminModerationLimitsController), +) +router.patch( + '/moderation/limits', + adminRateLimitMiddleware, + adminAuthGateMiddleware, + adminJsonBodyMiddleware, + adminAuthMiddleware, + withAdminController(createPatchAdminModerationLimitsController), +) +router.post( + '/moderation/actions', + adminRateLimitMiddleware, + adminAuthGateMiddleware, + adminJsonBodyMiddleware, + adminAuthMiddleware, + withAdminController(createPostAdminModerationActionsController), +) +router.get( + '/moderation/stats', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminModerationStatsController), +) router.get( '/settings', adminRateLimitMiddleware, @@ -136,5 +200,29 @@ router.post( adminAuthMiddleware, withAdminController(createPostAdminNotificationTestController), ) +router.get( + '/ops/workers', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminOpsWorkersController), +) +router.get( + '/ops/nip05-verifications', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminOpsNip05VerificationsController), +) +router.post( + '/ops/nip05-verifications/:pubkey/reverify', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createPostAdminOpsNip05ReverifyController), +) +router.get( + '/ops/storage/counts', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminOpsStorageCountsController), +) export default router diff --git a/src/routes/index.ts b/src/routes/index.ts index d7ff1b50e..fbb407f17 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -9,6 +9,7 @@ import { getPrivacyRequestHandler } from '../handlers/request-handlers/get-priva import { getReadyzRequestHandler } from '../handlers/request-handlers/get-readyz-request-handler' import { getTermsRequestHandler } from '../handlers/request-handlers/get-terms-request-handler' import invoiceRouter from './invoices' +import subscriptionRouter from './subscriptions' import { rateLimiterMiddleware } from '../handlers/request-handlers/rate-limiter-middleware' import { hasExplicitNostrJsonAcceptHeader, rootRequestHandler } from '../handlers/request-handlers/root-request-handler' @@ -37,6 +38,7 @@ router.get('/nodeinfo/2.0', nodeinfo21Handler) router.use('/admin', adminRouter) router.use('/invoices', rateLimiterMiddleware, invoiceRouter) router.use('/admissions', rateLimiterMiddleware, admissionRouter) +router.use('/subscriptions', rateLimiterMiddleware, subscriptionRouter) router.use('/callbacks', rateLimiterMiddleware, callbacksRouter) export default router diff --git a/src/routes/subscriptions/index.ts b/src/routes/subscriptions/index.ts new file mode 100644 index 000000000..1fe7fcbc2 --- /dev/null +++ b/src/routes/subscriptions/index.ts @@ -0,0 +1,9 @@ +import { createGetSubscriptionStatusController } from '../../factories/controllers/get-subscription-status-controller-factory' +import { Router } from 'express' +import { withController } from '../../handlers/request-handlers/with-controller-request-handler' + +const subscriptionRouter: Router = Router() + +subscriptionRouter.get('/status', withController(createGetSubscriptionStatusController)) + +export default subscriptionRouter diff --git a/src/schemas/admin-moderation-schema.ts b/src/schemas/admin-moderation-schema.ts new file mode 100644 index 000000000..dc7212f17 --- /dev/null +++ b/src/schemas/admin-moderation-schema.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' + +import { kindSchema } from './base-schema' + +// Match event admission (`kindSchema`), which accepts any non-negative integer, +// so moderation rules can configure and round-trip every kind admission allows. +const kindRuleSchema = z.union([ + kindSchema, + z.tuple([kindSchema, kindSchema]).refine(([start, end]) => start <= end, { + message: 'Kind range start must not exceed its end', + }), +]) + +export const adminModerationLimitsPatchSchema = z + .object({ + pubkey: z + .object({ + blacklist: z.array(z.string()).optional(), + whitelist: z.array(z.string()).optional(), + }) + .optional(), + kind: z + .object({ + blacklist: z.array(kindRuleSchema).optional(), + whitelist: z.array(kindRuleSchema).optional(), + }) + .optional(), + rateLimitBypass: z + .object({ + pubkeys: z.array(z.string()).optional(), + ipAddresses: z.array(z.string()).optional(), + }) + .optional(), + }) + .strict() + +export const adminModerationActionSchema = z + .object({ + action: z.enum([ + 'ban-pubkey', + 'unban-pubkey', + 'whitelist-pubkey', + 'unwhitelist-pubkey', + 'whitelist-ip', + 'unwhitelist-ip', + ]), + pubkey: z.string().optional(), + ip: z.string().optional(), + }) + .strict() diff --git a/src/schemas/admin-notifications-schema.ts b/src/schemas/admin-notifications-schema.ts index c7d05c0ac..bba837296 100644 --- a/src/schemas/admin-notifications-schema.ts +++ b/src/schemas/admin-notifications-schema.ts @@ -6,6 +6,7 @@ const targetSchema = z type: z.enum(['http', 'discord', 'slack', 'telegram']), enabled: z.boolean(), url: z.string().optional(), + secret: z.string().optional(), botToken: z.string().optional(), chatId: z.string().optional(), }) @@ -22,6 +23,9 @@ export const adminNotificationsPatchBodySchema = z 'admission.invoice.failed': z.boolean().optional(), 'settings.changed': z.boolean().optional(), 'relay.restarted': z.boolean().optional(), + 'probe.failed': z.boolean().optional(), + 'probe.recovered': z.boolean().optional(), + 'ssl.expiring': z.boolean().optional(), }) .strict() .optional(), diff --git a/src/schemas/message-schema.ts b/src/schemas/message-schema.ts index c246b4eee..6a928d4e4 100644 --- a/src/schemas/message-schema.ts +++ b/src/schemas/message-schema.ts @@ -55,7 +55,30 @@ export const countMessageSchema = z export const closeMessageSchema = z.tuple([z.literal(MessageType.CLOSE), subscriptionSchema]) +// NIP-77 +const negentropyHexSchema = z.string().regex(/^(?:[0-9a-fA-F]{2})+$/, 'negentropy message must be hex-encoded bytes') + +export const negOpenMessageSchema = z.tuple([ + z.literal(MessageType.NEG_OPEN), + z.string().max(256).min(1), + filterSchema, + negentropyHexSchema, +]) + +export const negMsgMessageSchema = z.tuple([z.literal(MessageType.NEG_MSG), z.string().max(256).min(1), negentropyHexSchema]) + +export const negCloseMessageSchema = z.tuple([z.literal(MessageType.NEG_CLOSE), z.string().max(256).min(1)]) + // NIP-42 export const authMessageSchema = z.tuple([z.literal(MessageType.AUTH), eventSchema]) -export const messageSchema = z.union([eventMessageSchema, reqMessageSchema, closeMessageSchema, countMessageSchema, authMessageSchema]) +export const messageSchema = z.union([ + eventMessageSchema, + reqMessageSchema, + closeMessageSchema, + countMessageSchema, + authMessageSchema, + negOpenMessageSchema, + negMsgMessageSchema, + negCloseMessageSchema, +]) diff --git a/src/scripts/benchmark-queries.ts b/src/scripts/benchmark-queries.ts index ac76a91e7..8fe5d5c46 100644 --- a/src/scripts/benchmark-queries.ts +++ b/src/scripts/benchmark-queries.ts @@ -157,7 +157,7 @@ async function explain(db: Knex, query: Knex.QueryBuilder | Knex.Raw): Promise( `EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON) ${sql}`, - bindings as readonly unknown[], + bindings, ) return rows[0]['QUERY PLAN'][0] } diff --git a/src/services/event-import-service.ts b/src/services/event-import-service.ts index 68f8cdf76..460ac4de9 100644 --- a/src/services/event-import-service.ts +++ b/src/services/event-import-service.ts @@ -1,9 +1,10 @@ import fs from 'fs' import readline from 'readline' -const streamArray = require('stream-json/streamers/stream-array.js') as { - withParserAsStream: () => NodeJS.ReadWriteStream +const streamArrayModule = require('stream-json/streamers/stream-array.js') as { + default: { withParserAsStream: () => NodeJS.ReadWriteStream } } +const streamArray = streamArrayModule.default import { getEventExpiration, diff --git a/src/services/maintenance-service.ts b/src/services/maintenance-service.ts index f0fa03d65..5f5949471 100644 --- a/src/services/maintenance-service.ts +++ b/src/services/maintenance-service.ts @@ -1,6 +1,7 @@ import { createLogger } from '../factories/logger-factory' import { IEventRepository } from '../@types/repositories' import { IMaintenanceService } from '../@types/services' +import { areSubscriptionsEnabled, getSubscriptionPlans } from '../utils/subscription' import { Settings } from '../@types/settings' const logger = createLogger('maintenance-service') @@ -22,8 +23,10 @@ export class MaintenanceService implements IMaintenanceService { try { logger('purging deleted, expired and old events') + const planRetention = this.getPlanRetention(currentSettings) const deletedCounts = await this.eventRepository.deleteExpiredAndRetained({ maxDays, + ...(planRetention.length ? { planRetention } : {}), kindWhitelist: retention?.kind?.whitelist, pubkeyWhitelist: retention?.pubkey?.whitelist, }) @@ -37,4 +40,16 @@ export class MaintenanceService implements IMaintenanceService { logger.error('Unable to purge events. Reason:', error) } } + + private getPlanRetention(settings: Settings): { planId: string; maxDays: number }[] { + if (!areSubscriptionsEnabled(settings)) { + return [] + } + + return getSubscriptionPlans(settings).flatMap((plan) => + typeof plan.limits?.retentionMaxDays === 'number' + ? [{ planId: plan.id, maxDays: plan.limits.retentionMaxDays }] + : [], + ) + } } diff --git a/src/services/notification-channels.ts b/src/services/notification-channels.ts index dde8cc4cc..7f49ec8f5 100644 --- a/src/services/notification-channels.ts +++ b/src/services/notification-channels.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import { createHmac } from 'crypto' import { OperatorNotificationChannelType, @@ -31,9 +32,16 @@ const deliverHttp = async (target: OperatorNotificationTarget, envelope: Operato throw new Error('HTTP notification target requires url') } - await axios.post(target.url, envelope, { + const body = JSON.stringify(envelope) + const headers: Record = { 'content-type': 'application/json' } + if (target.secret) { + const digest = createHmac('sha256', target.secret).update(body).digest('hex') + headers['x-nostream-signature'] = `sha256=${digest}` + } + + await axios.post(target.url, body, { timeout: REQUEST_TIMEOUT_MS, - headers: { 'content-type': 'application/json' }, + headers, validateStatus: (status) => status >= 200 && status < 300, }) } diff --git a/src/services/payments-service.ts b/src/services/payments-service.ts index a1fb91bb9..41cfb112e 100644 --- a/src/services/payments-service.ts +++ b/src/services/payments-service.ts @@ -2,7 +2,13 @@ import { andThen, otherwise, pipe } from 'ramda' import { broadcastEvent, getPublicKey, getRelayPrivateKey, identifyEvent, signEvent } from '../utils/event' import { DatabaseClient, Pubkey } from '../@types/base' import { FeeSchedule, Settings } from '../@types/settings' -import { IEventRepository, IInvoiceRepository, INotificationOutboxRepository, IUserRepository } from '../@types/repositories' +import { + IEventRepository, + IInvoiceRepository, + INotificationOutboxRepository, + IUserRepository, + IUserSubscriptionRepository, +} from '../@types/repositories' import { OperatorNotificationEventType } from '../@types/operator-notifications' import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice' @@ -12,6 +18,7 @@ import { createLogger } from '../factories/logger-factory' import { IPaymentsProcessor } from '../@types/clients' import { IPaymentsService } from '../@types/services' import { Transaction } from '../database/transaction' +import { applyPayment, areSubscriptionsEnabled, findSubscriptionPlan } from '../utils/subscription' import { resolveInvoiceExpiry } from '../utils/invoice' const logger = createLogger('payments-service') @@ -28,6 +35,7 @@ export class PaymentsService implements IPaymentsService { private readonly eventRepository: IEventRepository, private readonly settings: () => Settings, private readonly notificationOutboxRepository: INotificationOutboxRepository, + private readonly userSubscriptionRepository?: IUserSubscriptionRepository, ) {} public async getPendingInvoices(offset = 0): Promise { @@ -53,7 +61,7 @@ export class PaymentsService implements IPaymentsService { } } - public async createInvoice(pubkey: Pubkey, amount: bigint, description: string): Promise { + public async createInvoice(pubkey: Pubkey, amount: bigint, description: string, planId?: string): Promise { logger('create invoice for %s for %s: %s', pubkey, amount.toString(), description) const transaction = new Transaction(this.dbClient) @@ -88,6 +96,7 @@ export class PaymentsService implements IPaymentsService { updatedAt: date, createdAt: date, verifyURL: invoiceResponse.verifyURL, + planId, }, transaction.transaction, ) @@ -117,6 +126,7 @@ export class PaymentsService implements IPaymentsService { updatedAt: date, createdAt: invoiceResponse.createdAt, verifyURL: invoiceResponse.verifyURL, + planId, } } catch (error) { await transaction.rollback() @@ -168,6 +178,14 @@ export class PaymentsService implements IPaymentsService { await transaction.begin() + const currentSettings = this.settings() + const subscriptionsEnabled = areSubscriptionsEnabled(currentSettings) + // The callbacks only pass what their processor reports, so the plan, the unit and whether the + // invoice was already confirmed come from the stored invoice, read before it is confirmed. + const stored = subscriptionsEnabled + ? await this.invoiceRepository.findById(invoice.id, transaction.transaction) + : undefined + await this.invoiceRepository.confirmInvoice( invoice.id, invoice.amountPaid, @@ -175,13 +193,12 @@ export class PaymentsService implements IPaymentsService { transaction.transaction, ) - const currentSettings = this.settings() - let amountPaidMsat = invoice.amountPaid - if (invoice.unit === InvoiceUnit.SATS) { + const unit = invoice.unit ?? stored?.unit + if (unit === InvoiceUnit.SATS) { amountPaidMsat *= 1000n - } else if (invoice.unit === InvoiceUnit.BTC) { + } else if (unit === InvoiceUnit.BTC) { amountPaidMsat *= 1000n * 100000000n } @@ -192,7 +209,24 @@ export class PaymentsService implements IPaymentsService { return sum + (isApplicableFee(feeSchedule) ? BigInt(feeSchedule.amount) : 0n) }, 0n) - if (admissionFeeAmount > 0n && amountPaidMsat >= admissionFeeAmount) { + const plan = findSubscriptionPlan(currentSettings, invoice.planId ?? stored?.planId) + + if (plan && this.userSubscriptionRepository) { + if (stored?.confirmedAt) { + // A repeated callback must not grant another period. + logger('invoice %s was already confirmed, not extending the subscription again', invoice.id) + } else if (amountPaidMsat >= BigInt(plan.amount)) { + const date = new Date() + const existing = await this.userSubscriptionRepository.findByPubkey(invoice.pubkey, transaction.transaction) + await this.userSubscriptionRepository.upsert( + applyPayment(invoice.pubkey, existing, plan, date), + transaction.transaction, + ) + await this.userRepository.admitUser(invoice.pubkey, date, transaction.transaction) + } else { + logger('invoice %s paid %s msats, below plan %s price %s', invoice.id, amountPaidMsat, plan.id, plan.amount) + } + } else if (admissionFeeAmount > 0n && amountPaidMsat >= admissionFeeAmount) { const date = new Date() await this.userRepository.admitUser(invoice.pubkey, date, transaction.transaction) } @@ -220,14 +254,6 @@ export class PaymentsService implements IPaymentsService { public async sendInvoiceUpdateNotification(invoice: Invoice): Promise { logger('invoice updated notification %s: %o', invoice.id, invoice) - const currentSettings = this.settings() - - const { - info: { relay_url: relayUrl }, - } = currentSettings - - const relayPrivkey = getRelayPrivateKey(relayUrl) - const relayPubkey = getPublicKey(relayPrivkey) let unit: string = invoice.unit let amount: bigint | undefined = invoice.amountPaid @@ -245,11 +271,40 @@ export class PaymentsService implements IPaymentsService { const now = new Date() const expiration = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()) + await this.publishInvoiceNotification(invoice, `Invoice paid: ${amount.toString()} ${unit}`, expiration) + } + + public async sendRenewalNotification(invoice: Invoice): Promise { + logger('subscription renewal notification %s: %o', invoice.id, invoice) + + let unit: string = invoice.unit + let amount = invoice.amountRequested + if (invoice.unit === InvoiceUnit.MSATS) { + amount /= 1000n + unit = InvoiceUnit.SATS + } + + // The notice is only useful while the invoice can still be paid. + const expiration = invoice.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000) + + await this.publishInvoiceNotification(invoice, `Subscription renewal due: ${amount.toString()} ${unit}`, expiration) + } + + private async publishInvoiceNotification(invoice: Invoice, content: string, expiration: Date): Promise { + const { + info: { relay_url: relayUrl }, + } = this.settings() + + const relayPrivkey = getRelayPrivateKey(relayUrl) + const relayPubkey = getPublicKey(relayPrivkey) + + const now = new Date() + const unsignedInvoiceEvent: UnidentifiedEvent & Pick = { pubkey: relayPubkey, kind: EventKinds.INVOICE_UPDATE, created_at: Math.floor(now.getTime() / 1000), - content: `Invoice paid: ${amount.toString()} ${unit}`, + content, tags: [ [EventTags.Pubkey, invoice.pubkey], [EventTags.Invoice, invoice.bolt11], diff --git a/src/services/subscription-service.ts b/src/services/subscription-service.ts new file mode 100644 index 000000000..9744d16d2 --- /dev/null +++ b/src/services/subscription-service.ts @@ -0,0 +1,150 @@ +import { IPaymentsService, ISubscriptionService } from '../@types/services' +import { Settings } from '../@types/settings' +import { SubscriptionStatus, UserSubscription } from '../@types/user-subscription' +import { IUserSubscriptionRepository } from '../@types/repositories' +import { areSubscriptionsEnabled, endPeriod, findSubscriptionPlan, RENEWAL_LEAD_SECONDS } from '../utils/subscription' +import { createLogger } from '../factories/logger-factory' +import { toBech32 } from '../utils/transform' + +const logger = createLogger('subscription-service') + +/** Subscriptions per query, and how many queries one pass makes before leaving the rest to the next. */ +const SWEEP_PAGE_SIZE = 10 +const SWEEP_MAX_PAGES = 10 + +type Sweep = 'ended' | 'grace' | 'renewal' + +export class SubscriptionService implements ISubscriptionService { + /** + * Where each sweep starts. Rows a pass could not handle stay at the front of its query, so the next + * pass starts behind them. Without this, more unhandled rows than one pass reads would hide every + * row after them. It starts over from the front once a pass reaches the end of the rows. + */ + private readonly cursors: Record = { ended: 0, grace: 0, renewal: 0 } + + public constructor( + private readonly userSubscriptionRepository: IUserSubscriptionRepository, + private readonly paymentsService: IPaymentsService, + private readonly settings: () => Settings, + ) {} + + public async processLifecycle(now = new Date()): Promise { + const currentSettings = this.settings() + if (!areSubscriptionsEnabled(currentSettings)) { + return 0 + } + + // Ends first, so a subscription that lapsed is not also sent a renewal invoice. + const changed = + (await this.sweep( + 'ended', + (limit, offset) => this.userSubscriptionRepository.findPeriodEnded(now, limit, offset), + (subscription) => this.endPeriod(subscription, currentSettings, now), + )) + + (await this.sweep( + 'grace', + (limit, offset) => this.userSubscriptionRepository.findGraceEnded(now, limit, offset), + (subscription) => this.expire(subscription), + )) + + (await this.sweep( + 'renewal', + (limit, offset) => + this.userSubscriptionRepository.findDueForRenewal( + new Date(now.getTime() + RENEWAL_LEAD_SECONDS * 1000), + limit, + offset, + ), + (subscription) => this.requestRenewal(subscription, currentSettings), + )) + + return changed + } + + /** + * Each handled subscription leaves the query that found it, so the next page starts where the + * unhandled ones end: skipping them with an offset keeps a subscription that cannot be handled, + * such as one on a plan the operator removed, from blocking the ones behind it. The work of one + * pass is bounded, and the next one carries on behind the rows this one skipped. + */ + private async sweep( + sweep: Sweep, + find: (limit: number, offset: number) => Promise, + handle: (subscription: UserSubscription) => Promise, + ): Promise { + let changed = 0 + let skipped = this.cursors[sweep] + let reachedEnd = false + + for (let page = 0; page < SWEEP_MAX_PAGES; page++) { + const subscriptions = await find(SWEEP_PAGE_SIZE, skipped) + + for (const subscription of subscriptions) { + let handled = false + try { + handled = await handle(subscription) + } catch (error) { + logger.error('Unable to process subscription for %s. Reason:', subscription.pubkey, error) + } + + if (handled) { + changed++ + } else { + skipped++ + } + } + + if (subscriptions.length < SWEEP_PAGE_SIZE) { + reachedEnd = true + break + } + } + + this.cursors[sweep] = reachedEnd ? 0 : skipped + + return changed + } + + private async requestRenewal(subscription: UserSubscription, settings: Settings): Promise { + const plan = findSubscriptionPlan(settings, subscription.planId) + if (!plan) { + logger('plan %s of %s is no longer configured, not renewing', subscription.planId, subscription.pubkey) + return false + } + + const description = `${settings.info.name} ${plan.id} subscription renewal for ${toBech32('npub')(subscription.pubkey)}` + const invoice = await this.paymentsService.createInvoice( + subscription.pubkey, + BigInt(plan.amount), + description, + plan.id, + ) + + // The subscriber is told about the invoice either way: it can be paid whatever the status says. + await this.paymentsService.sendRenewalNotification(invoice) + + if (subscription.status === SubscriptionStatus.ACTIVE) { + return this.userSubscriptionRepository.updateIfCurrent(subscription, { + status: SubscriptionStatus.RENEWAL_PENDING, + }) + } + + return true + } + + private async endPeriod(subscription: UserSubscription, settings: Settings, now: Date): Promise { + const plan = findSubscriptionPlan(settings, subscription.planId) + if (!plan) { + logger('plan %s of %s is no longer configured, not ending its period', subscription.planId, subscription.pubkey) + return false + } + + return this.userSubscriptionRepository.updateIfCurrent(subscription, endPeriod(subscription, plan, now)) + } + + private async expire(subscription: UserSubscription): Promise { + return this.userSubscriptionRepository.updateIfCurrent(subscription, { + status: SubscriptionStatus.EXPIRED, + graceEndsAt: null, + }) + } +} diff --git a/src/telemetry/metrics.ts b/src/telemetry/metrics.ts index 75d3f143a..3b9b84205 100644 --- a/src/telemetry/metrics.ts +++ b/src/telemetry/metrics.ts @@ -20,7 +20,7 @@ export interface RelayMetricInstruments { let relayMetricInstruments: RelayMetricInstruments | undefined let meterProvider: MeterProvider | undefined -const getWorkerAttributes = () => { +export const getWorkerAttributes = () => { const workerType = process.env.WORKER_TYPE ?? (cluster.isPrimary ? 'primary' : 'worker') const workerIndex = process.env.WORKER_INDEX diff --git a/src/telemetry/ops-metrics.ts b/src/telemetry/ops-metrics.ts new file mode 100644 index 000000000..c4fb68a31 --- /dev/null +++ b/src/telemetry/ops-metrics.ts @@ -0,0 +1,47 @@ +import { metrics, type Counter, type Histogram } from '@opentelemetry/api' + +import { createLogger } from '../factories/logger-factory' +import { getWorkerAttributes } from './metrics' + +const logger = createLogger('telemetry-ops-metrics') + +export interface OpsMetricInstruments { + probeRunsTotal: Counter + probeRunDurationMs: Histogram +} + +let opsMetricInstruments: OpsMetricInstruments | undefined + +const createOpsMetricInstruments = (): OpsMetricInstruments => { + const meter = metrics.getMeter('nostream-ops') + + return { + probeRunsTotal: meter.createCounter('nostream.probe.runs_total', { + description: 'NIP-66 probe runs by outcome', + }), + probeRunDurationMs: meter.createHistogram('nostream.probe.run_duration_ms', { + description: 'Wall-clock duration of a probe run in milliseconds', + unit: 'ms', + }), + } +} + +export const getOpsMetricInstruments = (): OpsMetricInstruments => { + if (!opsMetricInstruments) { + try { + opsMetricInstruments = createOpsMetricInstruments() + } catch (error) { + logger.warn('unable to initialize ops metrics: %o', error) + opsMetricInstruments = createOpsMetricInstruments() + } + } + + return opsMetricInstruments +} + +export const recordProbeRunMetrics = (status: string, durationMs: number): void => { + const instruments = getOpsMetricInstruments() + const attributes = { ...getWorkerAttributes(), status } + instruments.probeRunsTotal.add(1, attributes) + instruments.probeRunDurationMs.record(durationMs, attributes) +} diff --git a/src/utils/admin-audit-store.ts b/src/utils/admin-audit-store.ts new file mode 100644 index 000000000..ee5a8ef52 --- /dev/null +++ b/src/utils/admin-audit-store.ts @@ -0,0 +1,68 @@ +import { randomUUID } from 'crypto' + +import { ICacheAdapter } from '../@types/adapters' +import { + ADMIN_AUDIT_LOG_KEY, + AdminAuditAction, + AdminAuditEntry, + MAX_ADMIN_AUDIT_API_LIMIT, + MAX_ADMIN_AUDIT_ENTRIES, +} from '../@types/admin-audit' +import { createLogger } from '../factories/logger-factory' + +const logger = createLogger('admin-audit-store') + +export class AdminAuditStore { + public constructor(private readonly cache: ICacheAdapter) {} + + public async append( + action: AdminAuditAction, + fields: Omit = {}, + ): Promise { + const entry: AdminAuditEntry = { + id: randomUUID(), + at: new Date().toISOString(), + action, + ...fields, + } + + const score = Date.parse(entry.at) + if (!Number.isFinite(score)) { + throw new Error('Invalid audit timestamp') + } + + await this.cache.addToSortedSet(ADMIN_AUDIT_LOG_KEY, { [JSON.stringify(entry)]: String(score) }) + await this.trim() + return entry + } + + public async list(limit: number): Promise { + await this.trim() + const clamped = Math.min(Math.max(Math.floor(limit), 1), MAX_ADMIN_AUDIT_API_LIMIT) + const raw = await this.cache.getRangeFromSortedSet(ADMIN_AUDIT_LOG_KEY, -clamped, -1) + + return raw + .map((payload) => JSON.parse(payload) as AdminAuditEntry) + .reverse() + } + + private async trim(): Promise { + await this.cache.removeRangeByRankFromSortedSet(ADMIN_AUDIT_LOG_KEY, 0, -(MAX_ADMIN_AUDIT_ENTRIES + 1)) + } +} + +export const appendAdminAuditEntrySafely = async ( + cache: ICacheAdapter | undefined, + action: AdminAuditAction, + fields: Omit = {}, +): Promise => { + if (!cache) { + return + } + + try { + await new AdminAuditStore(cache).append(action, fields) + } catch (error) { + logger.warn('failed to append admin audit entry %s: %o', action, error) + } +} diff --git a/src/utils/admin-diagnostics-export.ts b/src/utils/admin-diagnostics-export.ts new file mode 100644 index 000000000..0511eb3a1 --- /dev/null +++ b/src/utils/admin-diagnostics-export.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'fs' +import { join } from 'path' + +import { Settings } from '../@types/settings' +import { RedisAdapter } from '../adapters/redis-adapter' +import { getCacheClient } from '../cache/client' +import { AdminOpsStore } from './admin-ops-store' +import { collectAdminHealthSnapshot } from './admin-health' +import { getRedactedAdminNotifications } from './admin-notifications-settings' +import { RelayProbeSnapshotStore } from './relay-probe-snapshot' +import { loadMergedSettings } from './settings-config' +import { redactSettingsSecrets } from './settings-redaction' + +export interface AdminDiagnosticsExport { + generatedAt: string + version: string + health: Awaited> + settings: Settings + notifications: ReturnType + networkHealth: { + latest: Awaited> + } + operations: { + workers: Awaited> + } +} + +const readPackageVersion = (): string => { + try { + const raw = readFileSync(join(process.cwd(), 'package.json'), 'utf-8') + const parsed = JSON.parse(raw) as { version?: string } + return parsed.version ?? 'unknown' + } catch { + return process.env.npm_package_version ?? 'unknown' + } +} + +export const buildAdminDiagnosticsExport = async ( + getSettings: () => Settings = loadMergedSettings, +): Promise => { + const cache = new RedisAdapter(getCacheClient()) + const opsStore = new AdminOpsStore(cache) + const snapshotStore = new RelayProbeSnapshotStore(cache) + + const [health, workers, latest] = await Promise.all([ + collectAdminHealthSnapshot(), + opsStore.listWorkerHeartbeats(), + snapshotStore.getLatest(), + ]) + + const settings = redactSettingsSecrets(getSettings()) as Settings + + return { + generatedAt: new Date().toISOString(), + version: readPackageVersion(), + health, + settings, + notifications: getRedactedAdminNotifications(), + networkHealth: { latest }, + operations: { workers }, + } +} diff --git a/src/utils/admin-grafana.ts b/src/utils/admin-grafana.ts index ca0d4c01c..b3050b9d3 100644 --- a/src/utils/admin-grafana.ts +++ b/src/utils/admin-grafana.ts @@ -19,20 +19,23 @@ export const getGrafanaDashboardUrl = (): string => { const baseUrl = getGrafanaBaseUrl() const dashboardUid = getGrafanaDashboardUid() - return `${baseUrl}/d/${dashboardUid}/nostream-overview?orgId=1&refresh=5s&theme=light` + return `${baseUrl}/d/${dashboardUid}/nostream-overview?orgId=1&refresh=5s&theme=dark` } -export const getGrafanaSoloPanelUrl = (panelId: number | string, theme: 'light' | 'dark' = 'light'): string => { +export const getGrafanaSoloPanelUrl = (panelId: number | string, theme: 'light' | 'dark' = 'dark'): string => { const baseUrl = getGrafanaBaseUrl() const dashboardUid = getGrafanaDashboardUid() const params = new URLSearchParams({ orgId: '1', panelId: String(panelId), + from: 'now-15m', + to: 'now', refresh: '5s', theme, + kiosk: '1', }) - return `${baseUrl}/d-solo/${dashboardUid}?${params.toString()}` + return `${baseUrl}/d-solo/${dashboardUid}/nostream-overview?${params.toString()}` } export const getGrafanaEmbedUrl = (): string => { diff --git a/src/utils/admin-moderation-persist.ts b/src/utils/admin-moderation-persist.ts new file mode 100644 index 000000000..625e86a4a --- /dev/null +++ b/src/utils/admin-moderation-persist.ts @@ -0,0 +1,57 @@ +import { Request } from 'express' + +import { OperatorNotificationEventType } from '../@types/operator-notifications' +import { INotificationOutboxRepository } from '../@types/repositories' +import { createLogger } from '../factories/logger-factory' +import { appendSettingsAuditLog } from './settings-config' +import { recordAdminAuditFromRequest } from './record-admin-audit' +import { + AdminModerationLimitsUpdater, + updateAdminModerationLimits, + ValidationError, +} from './admin-moderation' + +const logger = createLogger('admin-moderation-persist') + +const MODERATION_SETTINGS_PATHS = [ + 'limits.event.pubkey.blacklist', + 'limits.event.pubkey.whitelist', + 'limits.event.kind.blacklist', + 'limits.event.kind.whitelist', + 'limits.event.whitelists.pubkeys', + 'limits.event.whitelists.ipAddresses', +] as const + +export const saveModerationLimitsFromRequest = async ( + update: AdminModerationLimitsUpdater, + request: Request, + notificationOutboxRepository: INotificationOutboxRepository, + auditAction: 'moderation.limits.updated' | 'moderation.action', + auditDetail?: Record, +): Promise => { + try { + await updateAdminModerationLimits(update) + } catch (error) { + if (error instanceof ValidationError) { + throw error + } + throw error + } + + const changes = MODERATION_SETTINGS_PATHS.map((path) => ({ path, reload: 'hot-reload' as const })) + appendSettingsAuditLog({ + action: 'settings.updated', + changes, + remoteAddress: request.ip, + }) + await recordAdminAuditFromRequest(auditAction, request, auditDetail) + + try { + await notificationOutboxRepository.enqueue(OperatorNotificationEventType.SETTINGS_CHANGED, { + changes, + remoteAddress: request.ip, + }) + } catch (error) { + logger.error('Unable to enqueue moderation settings notification outbox event', error) + } +} diff --git a/src/utils/admin-moderation-stats.ts b/src/utils/admin-moderation-stats.ts new file mode 100644 index 000000000..a1fb30fec --- /dev/null +++ b/src/utils/admin-moderation-stats.ts @@ -0,0 +1,93 @@ +import { Knex } from 'knex' + +export const ADMIN_MODERATION_STATS_DEFAULT_WINDOW_HOURS = 168 +export const ADMIN_MODERATION_STATS_MAX_WINDOW_HOURS = 720 +export const ADMIN_MODERATION_STATS_QUERY_TIMEOUT_MS = 5000 + +export interface ModerationPublisherRow { + pubkey: string + eventCount: number +} + +export interface ModerationClientIpRow { + ip: string + eventCount: number +} + +export const parseModerationStatsWindowHours = (raw: unknown): { hours?: number; error?: string } => { + if (raw === undefined || raw === '') { + return { hours: ADMIN_MODERATION_STATS_DEFAULT_WINDOW_HOURS } + } + + const hours = Number(raw) + if (!Number.isInteger(hours) || hours < 1 || hours > ADMIN_MODERATION_STATS_MAX_WINDOW_HOURS) { + return { + error: `windowHours must be an integer between 1 and ${ADMIN_MODERATION_STATS_MAX_WINDOW_HOURS}`, + } + } + + return { hours } +} + +export const parseModerationStatsLimit = (raw: unknown): number => { + if (raw === undefined || raw === '') { + return 20 + } + + const limit = Number(raw) + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + return 20 + } + + return limit +} + +// `first_seen` is a timestamp without time zone written in UTC. Build the +// cutoff from the unix timestamp in SQL: a JS Date is sent with the process's +// local offset, which PostgreSQL ignores for timestamp-without-time-zone +// comparisons, shifting the window by the difference between the two zones. +const firstSeenCutoff = (db: Knex, sinceUnixSeconds: number) => + db.raw(`to_timestamp(?) at time zone 'UTC'`, [sinceUnixSeconds]) + +export const queryTopPublishingPubkeys = async ( + db: Knex, + sinceUnixSeconds: number, + limit: number, +): Promise => { + const result = await db('events') + .select(db.raw(`encode(event_pubkey, 'hex') as pubkey`)) + .count('* as eventCount') + .whereNull('deleted_at') + .where('first_seen', '>=', firstSeenCutoff(db, sinceUnixSeconds)) + .groupBy('event_pubkey') + .orderBy('eventCount', 'desc') + .limit(limit) + .timeout(ADMIN_MODERATION_STATS_QUERY_TIMEOUT_MS, { cancel: true }) + + return (result as unknown as Array<{ pubkey: string; eventCount: string | number }>).map((row) => ({ + pubkey: String(row.pubkey), + eventCount: Number(row.eventCount ?? 0), + })) +} + +export const queryTopClientIps = async ( + db: Knex, + sinceUnixSeconds: number, + limit: number, +): Promise => { + const result = await db('events') + .select(db.raw(`host(remote_address) as ip`)) + .count('* as eventCount') + .whereNull('deleted_at') + .whereNotNull('remote_address') + .where('first_seen', '>=', firstSeenCutoff(db, sinceUnixSeconds)) + .groupBy('remote_address') + .orderBy('eventCount', 'desc') + .limit(limit) + .timeout(ADMIN_MODERATION_STATS_QUERY_TIMEOUT_MS, { cancel: true }) + + return (result as unknown as Array<{ ip: string; eventCount: string | number }>).map((row) => ({ + ip: String(row.ip), + eventCount: Number(row.eventCount ?? 0), + })) +} diff --git a/src/utils/admin-moderation.ts b/src/utils/admin-moderation.ts new file mode 100644 index 000000000..d55c1be51 --- /dev/null +++ b/src/utils/admin-moderation.ts @@ -0,0 +1,319 @@ +import { isIP, SocketAddress } from 'node:net' +import { mergeDeepRight } from 'ramda' + +import { Settings } from '../@types/settings' +import { isHexPubkey, parseRelayPubkey } from './nip43-invites' +import { + loadDefaults, + loadMergedSettings, + loadUserSettings, + saveSettings, + validateSettings, + withSettingsWriteLock, +} from './settings-config' + +export interface AdminModerationLimits { + pubkey: { + blacklist: string[] + whitelist: string[] + } + kind: { + blacklist: NonNullable['event']>['kind']['blacklist'] + whitelist: NonNullable['event']>['kind']['whitelist'] + } + rateLimitBypass: { + pubkeys: string[] + ipAddresses: string[] + } +} + +export type AdminModerationAction = + | 'ban-pubkey' + | 'unban-pubkey' + | 'whitelist-pubkey' + | 'unwhitelist-pubkey' + | 'whitelist-ip' + | 'unwhitelist-ip' + +const normalizePubkeyList = (values: string[], label: string): string[] => { + const normalized: string[] = [] + const seen = new Set() + + for (const raw of values) { + const pubkey = parseRelayPubkey(raw) + if (!pubkey || !isHexPubkey(pubkey)) { + throw new Error(`${label} must contain 64-character hex pubkeys or npubs`) + } + + if (!seen.has(pubkey)) { + seen.add(pubkey) + normalized.push(pubkey) + } + } + + return normalized +} + +export const normalizeModerationIpAddress = (raw: string): string => { + const ip = raw.trim() + if (!ip || isIP(ip) === 0) { + throw new Error('ip must be a valid IPv4 or IPv6 address') + } + + const family = isIP(ip) + return new SocketAddress({ + address: ip, + family: family === 6 ? 'ipv6' : 'ipv4', + }).address +} + +const normalizeIpList = (values: string[], label: string): string[] => { + const normalized: string[] = [] + const seen = new Set() + + for (const raw of values) { + let ip: string + try { + ip = normalizeModerationIpAddress(raw) + } catch { + throw new Error(`${label} must contain valid IPv4 or IPv6 addresses`) + } + + if (!seen.has(ip)) { + seen.add(ip) + normalized.push(ip) + } + } + + return normalized +} + +export const readAdminModerationLimits = (): AdminModerationLimits => { + const settings = loadMergedSettings() + const eventLimits = settings.limits?.event + + return { + pubkey: { + blacklist: [...(eventLimits?.pubkey?.blacklist ?? [])], + whitelist: [...(eventLimits?.pubkey?.whitelist ?? [])], + }, + kind: { + blacklist: [...(eventLimits?.kind?.blacklist ?? [])], + whitelist: [...(eventLimits?.kind?.whitelist ?? [])], + }, + rateLimitBypass: { + pubkeys: [...(eventLimits?.whitelists?.pubkeys ?? [])], + ipAddresses: [...(eventLimits?.whitelists?.ipAddresses ?? [])], + }, + } +} + +const applyLimitsToUserSettings = (userSettings: Settings, limits: AdminModerationLimits): Settings => { + return { + ...userSettings, + limits: { + ...userSettings.limits, + event: { + ...userSettings.limits?.event, + pubkey: { + ...userSettings.limits?.event?.pubkey, + blacklist: limits.pubkey.blacklist, + whitelist: limits.pubkey.whitelist, + }, + kind: { + ...userSettings.limits?.event?.kind, + blacklist: limits.kind.blacklist, + whitelist: limits.kind.whitelist, + }, + whitelists: { + ...userSettings.limits?.event?.whitelists, + pubkeys: limits.rateLimitBypass.pubkeys, + ipAddresses: limits.rateLimitBypass.ipAddresses, + }, + }, + }, + } +} + +export type AdminModerationLimitsUpdater = (current: AdminModerationLimits) => AdminModerationLimits + +const persistAdminModerationLimitsUnlocked = (next: AdminModerationLimits): void => { + const normalized: AdminModerationLimits = { + pubkey: { + blacklist: normalizePubkeyList(next.pubkey.blacklist, 'pubkey blacklist'), + whitelist: normalizePubkeyList(next.pubkey.whitelist, 'pubkey whitelist'), + }, + kind: { + blacklist: [...next.kind.blacklist], + whitelist: [...next.kind.whitelist], + }, + rateLimitBypass: { + pubkeys: normalizePubkeyList(next.rateLimitBypass.pubkeys, 'rate-limit bypass pubkeys'), + ipAddresses: normalizeIpList(next.rateLimitBypass.ipAddresses, 'rate-limit bypass IP list'), + }, + } + + const userSettings = loadUserSettings() as Settings + const nextUserSettings = applyLimitsToUserSettings(userSettings, normalized) + const mergedNext = mergeDeepRight(loadDefaults(), nextUserSettings) as Settings + + const issues = validateSettings(mergedNext) + if (issues.length > 0) { + throw new ValidationError('Validation failed', issues) + } + + saveSettings(nextUserSettings) +} + +export const updateAdminModerationLimits = async (updater: AdminModerationLimitsUpdater): Promise => { + await withSettingsWriteLock(() => { + const current = readAdminModerationLimits() + persistAdminModerationLimitsUnlocked(updater(current)) + }) +} + +export const persistAdminModerationLimits = async (next: AdminModerationLimits): Promise => { + await updateAdminModerationLimits(() => next) +} + +export class ValidationError extends Error { + public constructor( + message: string, + public readonly issues: Array<{ path: string; message: string }>, + ) { + super(message) + this.name = 'ValidationError' + } +} + +export type AdminModerationLimitsPatch = { + pubkey?: Partial + kind?: Partial + rateLimitBypass?: Partial +} + +export const mergeAdminModerationLimitsPatch = ( + current: AdminModerationLimits, + patch: AdminModerationLimitsPatch, +): AdminModerationLimits => ({ + pubkey: { + blacklist: patch.pubkey?.blacklist ?? current.pubkey.blacklist, + whitelist: patch.pubkey?.whitelist ?? current.pubkey.whitelist, + }, + kind: { + blacklist: patch.kind?.blacklist ?? current.kind.blacklist, + whitelist: patch.kind?.whitelist ?? current.kind.whitelist, + }, + rateLimitBypass: { + pubkeys: patch.rateLimitBypass?.pubkeys ?? current.rateLimitBypass.pubkeys, + ipAddresses: patch.rateLimitBypass?.ipAddresses ?? current.rateLimitBypass.ipAddresses, + }, +}) + +const withoutValue = (list: T[], value: T): T[] => list.filter((entry) => entry !== value) + +const withoutNormalizedModerationIp = (list: string[], normalizedTarget: string): string[] => + list.filter((stored) => { + try { + return normalizeModerationIpAddress(stored) !== normalizedTarget + } catch { + return stored.trim().toLowerCase() !== normalizedTarget.trim().toLowerCase() + } + }) + +export const applyAdminModerationAction = ( + current: AdminModerationLimits, + action: AdminModerationAction, + fields: { pubkey?: string; ip?: string }, +): AdminModerationLimits => { + switch (action) { + case 'ban-pubkey': { + const pubkey = parseRelayPubkey(fields.pubkey) + if (!pubkey || !isHexPubkey(pubkey)) { + throw new Error('pubkey is required and must be a 64-character hex pubkey or npub') + } + return { + ...current, + pubkey: { + blacklist: [...new Set([...current.pubkey.blacklist.map((p) => p.toLowerCase()), pubkey])], + whitelist: current.pubkey.whitelist, + }, + } + } + case 'unban-pubkey': { + const pubkey = parseRelayPubkey(fields.pubkey) + if (!pubkey || !isHexPubkey(pubkey)) { + throw new Error('pubkey is required and must be a 64-character hex pubkey or npub') + } + return { + ...current, + pubkey: { + ...current.pubkey, + blacklist: withoutValue( + current.pubkey.blacklist.map((p) => p.toLowerCase()), + pubkey, + ), + }, + } + } + case 'whitelist-pubkey': { + const pubkey = parseRelayPubkey(fields.pubkey) + if (!pubkey || !isHexPubkey(pubkey)) { + throw new Error('pubkey is required and must be a 64-character hex pubkey or npub') + } + return { + ...current, + rateLimitBypass: { + ...current.rateLimitBypass, + pubkeys: [...new Set([...current.rateLimitBypass.pubkeys.map((p) => p.toLowerCase()), pubkey])], + }, + } + } + case 'unwhitelist-pubkey': { + const pubkey = parseRelayPubkey(fields.pubkey) + if (!pubkey || !isHexPubkey(pubkey)) { + throw new Error('pubkey is required and must be a 64-character hex pubkey or npub') + } + return { + ...current, + rateLimitBypass: { + ...current.rateLimitBypass, + pubkeys: withoutValue( + current.rateLimitBypass.pubkeys.map((p) => p.toLowerCase()), + pubkey, + ), + }, + } + } + case 'whitelist-ip': { + const rawIp = fields.ip?.trim() + if (!rawIp) { + throw new Error('ip is required and must be a valid IPv4 or IPv6 address') + } + const ip = normalizeModerationIpAddress(rawIp) + return { + ...current, + rateLimitBypass: { + ...current.rateLimitBypass, + ipAddresses: [...new Set([...current.rateLimitBypass.ipAddresses, ip])], + }, + } + } + case 'unwhitelist-ip': { + const rawIp = fields.ip?.trim() + if (!rawIp) { + throw new Error('ip is required and must be a valid IPv4 or IPv6 address') + } + const ip = normalizeModerationIpAddress(rawIp) + return { + ...current, + rateLimitBypass: { + ...current.rateLimitBypass, + ipAddresses: withoutNormalizedModerationIp(current.rateLimitBypass.ipAddresses, ip), + }, + } + } + default: + throw new Error('Unsupported moderation action') + } +} diff --git a/src/utils/admin-notifications-settings.ts b/src/utils/admin-notifications-settings.ts index 1d801a14d..c09fff249 100644 --- a/src/utils/admin-notifications-settings.ts +++ b/src/utils/admin-notifications-settings.ts @@ -48,6 +48,18 @@ const mergeTargetSecrets = ( merged.url = existing.url } + if (incoming.secret === '') { + delete merged.secret + } else if (isRedactedSecret(incoming.secret)) { + if (existing?.secret) { + merged.secret = existing.secret + } else { + delete merged.secret + } + } else if (incoming.secret === undefined && existing?.secret) { + merged.secret = existing.secret + } + if (isRedactedSecret(incoming.botToken)) { if (existing?.botToken) { merged.botToken = existing.botToken diff --git a/src/utils/admin-ops-config.ts b/src/utils/admin-ops-config.ts new file mode 100644 index 000000000..6fdaee741 --- /dev/null +++ b/src/utils/admin-ops-config.ts @@ -0,0 +1,76 @@ +import { cpus } from 'os' + +import { AdminOpsConfiguredWorker, AdminOpsWorkerHeartbeat, AdminOpsWorkerRow } from '../@types/admin-ops' +import { Settings } from '../@types/settings' +import { ADMIN_OPS_HEARTBEAT_TTL_SECONDS, isAdminOpsHeartbeatStale } from './admin-ops-store' + +const UNMONITORED_WORKER_TYPES = new Set(['worker', 'dvm-orchestrator']) + +export const isAdminOpsWorkerMonitored = (type: string): boolean => !UNMONITORED_WORKER_TYPES.has(type) + +export const listConfiguredWorkers = (settings: Settings): AdminOpsConfiguredWorker[] => { + const workers: AdminOpsConfiguredWorker[] = [] + + const workerCount = process.env.WORKER_COUNT + ? Number(process.env.WORKER_COUNT) + : settings.workers?.count || cpus().length + + for (let i = 0; i < workerCount; i++) { + workers.push({ + type: 'worker', + index: i.toString(), + enabled: true, + label: `Client worker ${i}`, + }) + } + + workers.push({ + type: 'maintenance', + enabled: true, + label: 'Maintenance', + }) + + if (settings.nip66?.enabled) { + workers.push({ + type: 'relay-monitor', + enabled: true, + label: 'Relay monitor (NIP-66)', + }) + } + + const dvmWorkers = settings.dvm?.workers + if (Array.isArray(dvmWorkers) && dvmWorkers.length) { + for (let i = 0; i < dvmWorkers.length; i++) { + workers.push({ + type: 'dvm-orchestrator', + index: i.toString(), + enabled: true, + label: `DVM orchestrator ${i}`, + }) + } + } + + return workers +} + +const heartbeatKey = (type: string, index?: string) => + typeof index === 'string' && index.length > 0 ? `${type}:${index}` : type + +export const mergeWorkerRows = ( + configured: AdminOpsConfiguredWorker[], + heartbeats: AdminOpsWorkerHeartbeat[], +): AdminOpsWorkerRow[] => { + const byKey = new Map(heartbeats.map((heartbeat) => [heartbeatKey(heartbeat.type, heartbeat.index), heartbeat])) + + return configured.map((worker) => { + const heartbeat = byKey.get(heartbeatKey(worker.type, worker.index)) + const monitored = isAdminOpsWorkerMonitored(worker.type) + return { + ...worker, + heartbeat, + monitored, + stale: + monitored && (!heartbeat || isAdminOpsHeartbeatStale(heartbeat.updatedAt, ADMIN_OPS_HEARTBEAT_TTL_SECONDS)), + } + }) +} diff --git a/src/utils/admin-ops-nip05.ts b/src/utils/admin-ops-nip05.ts new file mode 100644 index 000000000..a92b347c3 --- /dev/null +++ b/src/utils/admin-ops-nip05.ts @@ -0,0 +1,32 @@ +import { Nip05Verification } from '../@types/nip05' +import { AdminNip05VerificationAdminRow } from '../@types/admin-ops' +import { DEFAULT_NIP05_VERIFY_EXPIRATION_MS } from './nip05' +import { Settings } from '../@types/settings' + +export const getNip05VerifyExpirationMs = (settings: Settings): number => + settings.nip05?.verifyExpiration ?? DEFAULT_NIP05_VERIFY_EXPIRATION_MS + +export const isNip05VerificationStale = ( + verification: Pick, + verifyExpirationMs: number, + now = Date.now(), +): boolean => { + if (!verification.isVerified || !verification.lastVerifiedAt) { + return true + } + return now - verification.lastVerifiedAt.getTime() > verifyExpirationMs +} + +export const toAdminNip05Row = ( + verification: Nip05Verification, + verifyExpirationMs: number, +): AdminNip05VerificationAdminRow => ({ + pubkey: verification.pubkey, + nip05: verification.nip05, + domain: verification.domain, + isVerified: verification.isVerified, + failureCount: verification.failureCount, + lastVerifiedAt: verification.lastVerifiedAt ? verification.lastVerifiedAt.toISOString() : null, + lastCheckedAt: verification.lastCheckedAt.toISOString(), + stale: isNip05VerificationStale(verification, verifyExpirationMs), +}) diff --git a/src/utils/admin-ops-sanitize.ts b/src/utils/admin-ops-sanitize.ts new file mode 100644 index 000000000..8a6b9e45b --- /dev/null +++ b/src/utils/admin-ops-sanitize.ts @@ -0,0 +1,26 @@ +const MAX_OPS_ERROR_LENGTH = 240 + +export const sanitizeAdminOpsError = (error: unknown): string | undefined => { + if (error === undefined || error === null) { + return undefined + } + + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : JSON.stringify(error) + + const redacted = message + .replace(/(wss?|https?):\/\/[^\s]+/gi, '[url]') + .replace(/Bearer\s+\S+/gi, 'Bearer [redacted]') + .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[email]') + .trim() + + if (!redacted) { + return undefined + } + + return redacted.length > MAX_OPS_ERROR_LENGTH ? `${redacted.slice(0, MAX_OPS_ERROR_LENGTH)}…` : redacted +} diff --git a/src/utils/admin-ops-storage.ts b/src/utils/admin-ops-storage.ts new file mode 100644 index 000000000..ca6c8dda4 --- /dev/null +++ b/src/utils/admin-ops-storage.ts @@ -0,0 +1,88 @@ +import { SubscriptionFilter } from '../@types/subscription' + +export const ADMIN_OPS_STORAGE_MAX_KINDS = 20 +export const ADMIN_OPS_STORAGE_MAX_WINDOW_SECONDS = 90 * 86_400 +export const ADMIN_OPS_STORAGE_QUERY_TIMEOUT_MS = 5000 + +export interface AdminOpsStorageQuery { + kinds: number[] + since?: number + until?: number +} + +export const parseAdminOpsStorageQuery = ( + rawKinds: unknown, + rawSince: unknown, + rawUntil: unknown, +): { query?: AdminOpsStorageQuery; error?: string } => { + if (typeof rawKinds !== 'string' || !rawKinds.trim()) { + return { error: 'kinds query parameter is required (comma-separated event kinds)' } + } + + const kinds = rawKinds + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => Number(part)) + + if (!kinds.length || kinds.some((kind) => !Number.isInteger(kind) || kind < 0 || kind > 65535)) { + return { error: 'kinds must be a comma-separated list of integers between 0 and 65535' } + } + + if (kinds.length > ADMIN_OPS_STORAGE_MAX_KINDS) { + return { error: `at most ${ADMIN_OPS_STORAGE_MAX_KINDS} kinds are allowed per request` } + } + + const uniqueKinds = [...new Set(kinds)] + + let since: number | undefined + if (rawSince !== undefined && rawSince !== '') { + since = Number(rawSince) + if (!Number.isInteger(since) || since < 0) { + return { error: 'since must be a non-negative unix timestamp in seconds' } + } + } + + let until: number | undefined + if (rawUntil !== undefined && rawUntil !== '') { + until = Number(rawUntil) + if (!Number.isInteger(until) || until < 0) { + return { error: 'until must be a non-negative unix timestamp in seconds' } + } + } + + const now = Math.floor(Date.now() / 1000) + const effectiveUntil = until ?? now + const effectiveSince = since ?? effectiveUntil - 86_400 + + if (effectiveSince > effectiveUntil) { + return { error: 'since must be less than or equal to until' } + } + + if (effectiveUntil - effectiveSince > ADMIN_OPS_STORAGE_MAX_WINDOW_SECONDS) { + return { error: `time window must not exceed ${ADMIN_OPS_STORAGE_MAX_WINDOW_SECONDS} seconds` } + } + + return { + query: { + kinds: uniqueKinds, + since: effectiveSince, + until: effectiveUntil, + }, + } +} + +export const buildStorageCountFilters = (query: AdminOpsStorageQuery): SubscriptionFilter[] => { + const timeFilter: SubscriptionFilter = {} + if (typeof query.since === 'number') { + timeFilter.since = query.since + } + if (typeof query.until === 'number') { + timeFilter.until = query.until + } + + return query.kinds.map((kind) => ({ + ...timeFilter, + kinds: [kind], + })) +} diff --git a/src/utils/admin-ops-store.ts b/src/utils/admin-ops-store.ts new file mode 100644 index 000000000..7bf931fe8 --- /dev/null +++ b/src/utils/admin-ops-store.ts @@ -0,0 +1,110 @@ +import { ICacheAdapter } from '../@types/adapters' +import { AdminOpsWorkerHeartbeat } from '../@types/admin-ops' +import { createLogger } from '../factories/logger-factory' +import { sanitizeAdminOpsError } from './admin-ops-sanitize' + +const logger = createLogger('admin-ops-store') + +export const ADMIN_OPS_HEARTBEAT_INDEX_KEY = 'admin:ops:heartbeat:index' +export const ADMIN_OPS_HEARTBEAT_TTL_SECONDS = 120 +export const ADMIN_OPS_HEARTBEAT_KEEPALIVE_MS = 45_000 + +const workerKey = (type: string, index?: string) => + typeof index === 'string' && index.length > 0 ? `admin:ops:worker:${type}:${index}` : `admin:ops:worker:${type}` + +const indexMember = (category: 'worker', id: string) => `${category}:${id}` + +export class AdminOpsStore { + public constructor(private readonly cache: ICacheAdapter) {} + + public async publishWorker(state: AdminOpsWorkerHeartbeat): Promise { + const key = workerKey(state.type, state.index) + const member = indexMember('worker', typeof state.index === 'string' ? `${state.type}:${state.index}` : state.type) + await this.cache.setKey(key, JSON.stringify(state), ADMIN_OPS_HEARTBEAT_TTL_SECONDS) + await this.cache.addToSet(ADMIN_OPS_HEARTBEAT_INDEX_KEY, [member]) + await this.cache.setKeyExpiry(ADMIN_OPS_HEARTBEAT_INDEX_KEY, ADMIN_OPS_HEARTBEAT_TTL_SECONDS * 4) + } + + public async listWorkerHeartbeats(): Promise { + const members = await this.cache.getSetMembers(ADMIN_OPS_HEARTBEAT_INDEX_KEY) + const workerMembers = members.filter((member) => member.startsWith('worker:')) + + const heartbeats: AdminOpsWorkerHeartbeat[] = [] + for (const member of workerMembers) { + const id = member.slice('worker:'.length) + const separator = id.indexOf(':') + const type = separator >= 0 ? id.slice(0, separator) : id + const index = separator >= 0 ? id.slice(separator + 1) : undefined + const raw = await this.cache.getKey(workerKey(type, index)) + if (!raw) { + continue + } + try { + heartbeats.push(JSON.parse(raw) as AdminOpsWorkerHeartbeat) + } catch { + // ignore corrupt payloads + } + } + + return heartbeats.sort((a, b) => { + const typeCompare = a.type.localeCompare(b.type) + if (typeCompare !== 0) { + return typeCompare + } + return (a.index ?? '').localeCompare(b.index ?? '', undefined, { numeric: true }) + }) + } +} + +let sharedStore: AdminOpsStore | undefined +let sharedStoreCache: ICacheAdapter | undefined + +export const getAdminOpsStore = (cache: ICacheAdapter): AdminOpsStore => { + if (!sharedStore || sharedStoreCache !== cache) { + sharedStore = new AdminOpsStore(cache) + sharedStoreCache = cache + } + return sharedStore +} + +export const resetAdminOpsStore = (): void => { + sharedStore = undefined + sharedStoreCache = undefined +} + +const sanitizeWorkerHeartbeat = (state: AdminOpsWorkerHeartbeat): AdminOpsWorkerHeartbeat => ({ + ...state, + lastError: state.lastError !== undefined ? sanitizeAdminOpsError(state.lastError) : undefined, +}) + +export const publishAdminOpsWorkerHeartbeat = async ( + cache: ICacheAdapter, + state: AdminOpsWorkerHeartbeat, +): Promise => { + await getAdminOpsStore(cache).publishWorker(sanitizeWorkerHeartbeat(state)) +} + +export const publishAdminOpsWorkerHeartbeatSafely = async ( + cache: ICacheAdapter | undefined, + state: AdminOpsWorkerHeartbeat, +): Promise => { + if (!cache) { + return + } + try { + await publishAdminOpsWorkerHeartbeat(cache, state) + } catch (error) { + logger.warn('failed to publish worker ops heartbeat: %o', error) + } +} + +export const isAdminOpsHeartbeatStale = (updatedAtIso: string | undefined, ttlSeconds: number): boolean => { + if (!updatedAtIso) { + return true + } + const updatedAt = Date.parse(updatedAtIso) + if (!Number.isFinite(updatedAt)) { + return true + } + return Date.now() - updatedAt > ttlSeconds * 1000 +} diff --git a/src/utils/admin-rate-limit.ts b/src/utils/admin-rate-limit.ts index 06772ec81..6fff86f6d 100644 --- a/src/utils/admin-rate-limit.ts +++ b/src/utils/admin-rate-limit.ts @@ -27,7 +27,7 @@ export async function isAdminRateLimited( if (!Array.isArray(ipWhitelist) || !ipWhitelist.includes(remoteAddress)) { const rateLimiter = rateLimiterFactory() for (const { rate, period } of rateLimits) { - if (await rateLimiter.hit(`${remoteAddress}:admin-${scope}:${period}`, 1, { period, rate })) { + if ((await rateLimiter.hit(`${remoteAddress}:admin-${scope}:${period}`, 1, { period, rate })).limited) { logger('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period) limited = true } diff --git a/src/utils/banner.ts b/src/utils/banner.ts new file mode 100644 index 000000000..1ec5450e4 --- /dev/null +++ b/src/utils/banner.ts @@ -0,0 +1,36 @@ +// The startup banner is read by a human in `docker compose logs`, so it is +// composed here as text and written to stdout: through the logger it arrives as +// one JSON object per line with the newlines escaped, which is not a logo, and +// pino is for records rather than for artwork. +// +// Kept out of App so the shape can be tested without a database, a cluster or a +// settings document. +export const BANNER_WIDTH = 74 + +export const RELAY_BANNER_LINES: readonly string[] = [ + ' ███▄ █ ▒█████ ██████ ▄▄▄█████▓ ██▀███ ▓█████ ▄▄▄ ███▄ ▄███▓', + ' ██ ▀█ █ ▒██▒ ██▒▒██ ▒ ▓ ██▒ ▓▒▓██ ▒ ██▒▓█ ▀▒████▄ ▓██▒▀█▀ ██▒', + '▓██ ▀█ ██▒▒██░ ██▒░ ▓██▄ ▒ ▓██░ ▒░▓██ ░▄█ ▒▒███ ▒██ ▀█▄ ▓██ ▓██░', + '▓██▒ ▐▌██▒▒██ ██░ ▒ ██▒░ ▓██▓ ░ ▒██▀▀█▄ ▒▓█ ▄░██▄▄▄▄██ ▒██ ▒██', + '▒██░ ▓██░░ ████▓▒░▒██████▒▒ ▒██▒ ░ ░██▓ ▒██▒░▒████▒▓█ ▓██▒▒██▒ ░██▒', + '░ ▒░ ▒ ▒ ░ ▒░▒░▒░ ▒ ▒▓▒ ▒ ░ ▒ ░░ ░ ▒▓ ░▒▓░░░ ▒░ ░▒▒ ▓▒█░░ ▒░ ░ ░', + '░ ░░ ░ ▒░ ░ ▒ ▒░ ░ ░▒ ░ ░ ░ ░▒ ░ ▒░ ░ ░ ░ ▒ ▒▒ ░░ ░ ░', + ' ░ ░ ░ ░ ░ ░ ▒ ░ ░ ░ ░ ░░ ░ ░ ░ ▒ ░ ░', + ' ░ ░ ░ ░ ░ ░ ░ ░ ░ ░', +] + +/** + * Centres one line of the banner block. A line longer than the width is returned + * unpadded rather than truncated: half a logo is worse than a wide one. + */ +export const centre = (line: string, width: number = BANNER_WIDTH): string => + ' '.repeat(Math.max(0, (width - line.length) >> 1)) + line + +/** + * `LOG_FORMAT=json` promises a stream in which every line parses as JSON. The + * banner cannot keep that promise, so it is replaced by one record carrying the + * same facts. Anything else — unset, `pretty`, a typo — keeps the banner: the + * person reading `docker compose logs` is the default audience. + */ +export const isStructuredLogFormat = (format: string | undefined): boolean => + (format ?? '').trim().toLowerCase() === 'json' diff --git a/src/utils/ewma-rate-limiter.ts b/src/utils/ewma-rate-limiter.ts index 0f212c6b1..e36035d72 100644 --- a/src/utils/ewma-rate-limiter.ts +++ b/src/utils/ewma-rate-limiter.ts @@ -1,4 +1,4 @@ -import { IRateLimiter, IRateLimiterOptions } from '../@types/utils' +import { IRateLimiter, IRateLimiterOptions, IRateLimiterResult } from '../@types/utils' import { createLogger } from '../factories/logger-factory' import { ICacheAdapter } from '../@types/adapters' @@ -21,14 +21,28 @@ const rateLimitScript = { redis.call('HSET', key, 'rate', R_new, 'timestamp', timestamp) redis.call('EXPIRE', key, math.ceil(period / 1000)) - if R_new > rate then - return 1 - else - return 0 - end + -- A Lua number reaches the client truncated to an integer, and the score + -- is a float: tostring is what keeps the fraction the caller needs to + -- tell 6.4 hits from 24. + return tostring(R_new) `, } +// Every limiter key is namespaced by strategy, because the window layouts are +// not interchangeable: a sliding window stores a sorted set under a key named +// `::`, while EWMA stores a hash under the same name. +// The strategies share Redis and an operator can switch between them (and 2.x +// shipped only the sliding window), so reading a key this strategy did not +// write makes the script fail with WRONGTYPE on every request that hits it - +// and the failure path is the hot path, per connection and per message. +// +// A prefix, rather than deleting a key of the wrong type, keeps both strategies' +// data intact: switching back to sliding_window resumes its window instead of +// starting from zero. +export const EWMA_KEY_PREFIX = 'ewma:' + +export const ewmaKey = (key: string): string => `${EWMA_KEY_PREFIX}${key}` + export const calculateEWMA = ( rOld: number, deltaT: number, @@ -48,17 +62,50 @@ export class EWMARateLimiter implements IRateLimiter { key: string, step: number, options: IRateLimiterOptions, - ): Promise { + ): Promise { const { rate, period } = options const result = await this.cache.eval(rateLimitScript.SCRIPT, - [key], + [ewmaKey(key)], [Date.now().toString(), rate.toString(), period.toString(), step.toString()] ) - debug('ewma rate limited on %s bucket: %s', key, result ? 'yes' : 'no') + // A null/undefined answer means the script returned nil, which is a protocol + // disagreement, not a score of zero: reading it as "allowed" would silently + // disable the limiter. + if (typeof result !== 'string' && typeof result !== 'number') { + throw new Error(`ewma rate limiter returned no score: ${String(result)}`) + } + + const score = Number(result) + if (!Number.isFinite(score)) { + throw new Error(`ewma rate limiter returned a non-numeric score: ${String(result)}`) + } + + const limited = score > rate + + debug('ewma rate limited on %s bucket: %s (score %d, rate %d)', key, limited ? 'yes' : 'no', score, rate) + + if (!limited) { + return { limited: false } + } - return result === 1 + // `period` is the half-life, not the wait: the score decays as + // `score * 2^(-t/period)`, so it takes `period * log2(score / target)` to + // fall back to `target`. Reporting `period` understates a four-times + // overrun by half (rate 6, score 24, period 60 s: ~136 s, not 60 s). + // + // The target is `rate - step`, not `rate`: the script adds this hit's step + // before comparing, so a score that has decayed to exactly `rate` still + // exceeds it. With no headroom (`rate <= step`) no amount of decay admits + // another hit, and the key's TTL is what eventually clears it. + const headroom = rate - step + return { + limited: true, + retryAfterMs: headroom > 0 + ? Math.max(1, Math.ceil(period * Math.log2(score / headroom))) + : Math.max(1, Math.ceil(period)), + } } } diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 3c2544421..bc6b3ff6b 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -7,6 +7,8 @@ import { IncomingEventMessage, IncomingRelayedEventMessage, MessageType, + NegErrMessage, + NegMsgMessage, NoticeMessage, OutgoingMessage, SubscribeMessage, @@ -50,6 +52,21 @@ export const createAuthChallengeMessage = (challenge: string): AuthChallengeMess return [MessageType.AUTH, challenge] } +// NIP-77 +export const createNegMsgMessage = (subscriptionId: SubscriptionId, message: string): NegMsgMessage => { + return [MessageType.NEG_MSG, subscriptionId, message] +} + +export const createNegErrMessage = ( + subscriptionId: SubscriptionId, + reason: string, + maxRecords?: number, +): NegErrMessage => { + return maxRecords === undefined + ? [MessageType.NEG_ERR, subscriptionId, reason] + : [MessageType.NEG_ERR, subscriptionId, reason, maxRecords] +} + export const createSubscriptionMessage = ( subscriptionId: SubscriptionId, filters: SubscriptionFilter[], diff --git a/src/utils/moderation-rate-limit-stats.ts b/src/utils/moderation-rate-limit-stats.ts new file mode 100644 index 000000000..7933933c0 --- /dev/null +++ b/src/utils/moderation-rate-limit-stats.ts @@ -0,0 +1,140 @@ +import { randomBytes } from 'node:crypto' + +import { ICacheAdapter } from '../@types/adapters' +import { createLogger } from '../factories/logger-factory' + +const logger = createLogger('moderation-rate-limit-stats') + +const PUBKEY_HITS_PREFIX = 'admin:moderation:rate-limit:pubkeys' +const IP_HITS_PREFIX = 'admin:moderation:rate-limit:ips' +const STATS_RETENTION_DAYS = 7 + +const INCREMENT_BUCKET_SCRIPT = ` +redis.call('ZINCRBY', KEYS[1], 1, ARGV[1]) +redis.call('EXPIRE', KEYS[1], ARGV[2]) +return 1 +` + +const AGGREGATE_TOP_SCRIPT = ` +local dest = KEYS[1] +local sourceCount = #KEYS - 1 +if sourceCount < 1 then + return {} +end +redis.call('ZUNIONSTORE', dest, sourceCount, unpack(KEYS, 2)) +local rows = redis.call('ZREVRANGE', dest, 0, ARGV[1], 'WITHSCORES') +redis.call('DEL', dest) +return rows +` + +export interface ModerationRateLimitHitRow { + key: string + hits: number +} + +const hasEval = (cache: ICacheAdapter): cache is ICacheAdapter & { eval: ICacheAdapter['eval'] } => + typeof (cache as { eval?: unknown }).eval === 'function' + +export const utcDayBucketSuffix = (unixMs: number): string => { + const date = new Date(unixMs) + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + const day = String(date.getUTCDate()).padStart(2, '0') + return `${year}${month}${day}` +} + +const bucketKey = (prefix: string, unixMs: number): string => `${prefix}:${utcDayBucketSuffix(unixMs)}` + +const bucketTtlSeconds = (): number => STATS_RETENTION_DAYS * 86400 + +const utcDayStartMs = (unixMs: number): number => { + const date = new Date(unixMs) + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +} + +/** UTC day buckets that overlap the rolling window (not just the last N calendar days from now). */ +const listDayBucketSuffixes = (windowHours: number, nowMs = Date.now()): string[] => { + const clampedWindowHours = Math.min(Math.max(Math.floor(windowHours), 1), STATS_RETENTION_DAYS * 24) + const windowStartMs = nowMs - clampedWindowHours * 3600000 + const retentionStartMs = nowMs - STATS_RETENTION_DAYS * 86400000 + + let cursorMs = utcDayStartMs(Math.max(windowStartMs, retentionStartMs)) + const endDayMs = utcDayStartMs(nowMs) + const suffixes: string[] = [] + + while (cursorMs <= endDayMs) { + suffixes.push(utcDayBucketSuffix(cursorMs)) + cursorMs += 86400000 + } + + return suffixes +} + +export const recordModerationRateLimitHitSafely = async ( + cache: ICacheAdapter | undefined, + fields: { pubkey: string; ip?: string }, +): Promise => { + if (!cache || !hasEval(cache)) { + return + } + + const bucket = bucketKey(PUBKEY_HITS_PREFIX, Date.now()) + const ttl = String(bucketTtlSeconds()) + + try { + await cache.eval(INCREMENT_BUCKET_SCRIPT, [bucket], [fields.pubkey.toLowerCase(), ttl]) + if (fields.ip) { + const ipBucket = bucketKey(IP_HITS_PREFIX, Date.now()) + await cache.eval(INCREMENT_BUCKET_SCRIPT, [ipBucket], [fields.ip, ttl]) + } + } catch (error) { + logger.warn('failed to record moderation rate-limit hit: %o', error) + } +} + +const parseBucketRows = (result: unknown): ModerationRateLimitHitRow[] => { + if (!Array.isArray(result) || !result.length) { + return [] + } + + const rows: ModerationRateLimitHitRow[] = [] + for (let index = 0; index < result.length; index += 2) { + rows.push({ key: String(result[index]), hits: Number(result[index + 1] ?? 0) }) + } + return rows +} + +const fetchAggregatedHits = async ( + cache: ICacheAdapter, + prefix: string, + limit: number, + windowHours: number, + nowMs = Date.now(), +): Promise => { + const clamped = Math.min(Math.max(Math.floor(limit), 1), 100) + const suffixes = listDayBucketSuffixes(windowHours, nowMs) + if (!suffixes.length) { + return [] + } + + const destKey = `${prefix}:__agg:${randomBytes(8).toString('hex')}` + const sourceKeys = suffixes.map((suffix) => `${prefix}:${suffix}`) + const result = await cache.eval(AGGREGATE_TOP_SCRIPT, [destKey, ...sourceKeys], [String(clamped - 1)]) + + return parseBucketRows(result).slice(0, clamped) +} + +/** Top rate-limit hits in Redis daily buckets within the requested window. */ +export const listModerationRateLimitHits = async ( + cache: ICacheAdapter, + limit: number, + windowHours = STATS_RETENTION_DAYS * 24, + nowMs = Date.now(), +): Promise<{ pubkeys: ModerationRateLimitHitRow[]; ips: ModerationRateLimitHitRow[] }> => { + const [pubkeys, ips] = await Promise.all([ + fetchAggregatedHits(cache, PUBKEY_HITS_PREFIX, limit, windowHours, nowMs), + fetchAggregatedHits(cache, IP_HITS_PREFIX, limit, windowHours, nowMs), + ]) + + return { pubkeys, ips } +} diff --git a/src/utils/negentropy-sessions.ts b/src/utils/negentropy-sessions.ts new file mode 100644 index 000000000..952a213fc --- /dev/null +++ b/src/utils/negentropy-sessions.ts @@ -0,0 +1,117 @@ +import { INegentropySessions, NegentropySession, NegentropySessionToken } from '../@types/negentropy' +import { SubscriptionId } from '../@types/subscription' + +interface Entry { + token: NegentropySessionToken + session?: NegentropySession + idleTimeoutMs?: number + idleTimer?: NodeJS.Timeout +} + +/** The NIP-77 sessions of one websocket connection. Ids are a separate namespace from REQ ids. */ +export class NegentropySessions implements INegentropySessions { + private readonly entries = new Map() + // Event reads still running. A replaced request's read is not cancelled, so it stays counted here + // until it finishes; otherwise repeating NEG-OPEN on one id would stack up unbounded queries. + private readonly pendingReads = new Set() + + public constructor(private readonly onIdleTimeout: (subscriptionId: SubscriptionId) => void) {} + + public reserve(subscriptionId: SubscriptionId, maxLimit: number): NegentropySessionToken | undefined { + // NIP-77: opening an id that is already open closes the old session first. + this.close(subscriptionId) + + if (this.entries.size >= maxLimit || this.pendingReads.size >= maxLimit) { + return undefined + } + + const token = Symbol(subscriptionId) + this.entries.set(subscriptionId, { token }) + this.pendingReads.add(token) + + return token + } + + public endRead(token: NegentropySessionToken): void { + this.pendingReads.delete(token) + } + + public isCurrent(subscriptionId: SubscriptionId, token: NegentropySessionToken): boolean { + return this.entries.get(subscriptionId)?.token === token + } + + public activate( + subscriptionId: SubscriptionId, + token: NegentropySessionToken, + session: NegentropySession, + idleTimeoutMs: number, + ): boolean { + const entry = this.entries.get(subscriptionId) + if (!entry || entry.token !== token) { + return false + } + + // Activating means the read is over. + this.pendingReads.delete(token) + entry.session = session + entry.idleTimeoutMs = idleTimeoutMs + this.startIdleTimer(subscriptionId, entry) + + return true + } + + public touch(subscriptionId: SubscriptionId): NegentropySession | undefined { + const entry = this.entries.get(subscriptionId) + if (!entry?.session) { + return undefined + } + + this.startIdleTimer(subscriptionId, entry) + + return entry.session + } + + public release(subscriptionId: SubscriptionId, token: NegentropySessionToken): void { + if (this.entries.get(subscriptionId)?.token === token) { + this.close(subscriptionId) + } + } + + public close(subscriptionId: SubscriptionId): boolean { + const entry = this.entries.get(subscriptionId) + if (!entry) { + return false + } + + clearTimeout(entry.idleTimer) + this.entries.delete(subscriptionId) + + return true + } + + public closeAll(): void { + for (const subscriptionId of [...this.entries.keys()]) { + this.close(subscriptionId) + } + } + + public getSessionIds(): SubscriptionId[] { + return [...this.entries.entries()].filter(([, entry]) => entry.session).map(([subscriptionId]) => subscriptionId) + } + + private startIdleTimer(subscriptionId: SubscriptionId, entry: Entry): void { + clearTimeout(entry.idleTimer) + if (!entry.idleTimeoutMs || entry.idleTimeoutMs <= 0) { + return + } + + entry.idleTimer = setTimeout(() => { + if (this.entries.get(subscriptionId) === entry) { + this.entries.delete(subscriptionId) + this.onIdleTimeout(subscriptionId) + } + }, entry.idleTimeoutMs) + // An idle session must never keep the process alive. + entry.idleTimer.unref() + } +} diff --git a/src/utils/nip05-reverification.ts b/src/utils/nip05-reverification.ts new file mode 100644 index 000000000..f8039c270 --- /dev/null +++ b/src/utils/nip05-reverification.ts @@ -0,0 +1,24 @@ +import { INip05VerificationRepository } from '../@types/repositories' +import { Nip05Verification } from '../@types/nip05' +import { applyReverificationOutcome } from '../app/maintenance-worker' +import { verifyNip05Identifier } from './nip05' + +export const reverifyNip05Verification = async ( + verification: Nip05Verification, + repository: INip05VerificationRepository, +): Promise => { + const outcome = await verifyNip05Identifier(verification.nip05, verification.pubkey) + const current = await repository.findByPubkey(verification.pubkey) + + if (!current) { + return verification + } + + if (current.nip05 !== verification.nip05) { + return current + } + + const updated = applyReverificationOutcome(current, outcome) + await repository.upsert(updated) + return updated +} diff --git a/src/utils/nip42.ts b/src/utils/nip42.ts index 144695436..6d20f824d 100644 --- a/src/utils/nip42.ts +++ b/src/utils/nip42.ts @@ -89,6 +89,32 @@ export const isSubscriptionAuthRequired = ( return getAuthenticatedPubkeys().size === 0 } +// NIP-77 reconciliation, like COUNT, can't be filtered per event: it reveals the id of every event the +// filter matches. REQ hides restricted events per event, so a filter that *could* match one -- one +// naming a restricted kind or naming no kinds at all -- must be scoped to the client's own pubkeys. +export const isReconciliationAuthorized = ( + settings: Settings | undefined, + filter: SubscriptionFilter, + getAuthenticatedPubkeys: () => ReadonlySet, +): boolean => { + const restrictedKinds = getRestrictedReadKinds(settings) + if (!restrictedKinds.length) { + return true + } + + const mayMatchRestricted = + !Array.isArray(filter.kinds) || filter.kinds.some((kind) => isKindRestricted(restrictedKinds, kind)) + if (!mayMatchRestricted) { + return true + } + + const authenticatedPubkeys = getAuthenticatedPubkeys() + const isScopedToClient = (values?: Pubkey[]) => + Array.isArray(values) && values.length > 0 && values.every((value) => authenticatedPubkeys.has(value)) + + return isScopedToClient(filter.authors) || isScopedToClient(filter['#p']) +} + // COUNT can't be filtered per event, so a restricted-kind filter must be // scoped to the client's own pubkeys via authors/#p. export const isCountAuthorized = ( diff --git a/src/utils/nip66-schedule.ts b/src/utils/nip66-schedule.ts index aaf7aff25..2c2cf416b 100644 --- a/src/utils/nip66-schedule.ts +++ b/src/utils/nip66-schedule.ts @@ -2,6 +2,7 @@ import { Settings } from '../@types/settings' export const DEFAULT_PROBE_INTERVAL_SECONDS = 3600 export const MIN_PROBE_INTERVAL_SECONDS = 60 +export const DEFAULT_HISTORY_RETENTION_RUNS = 168 export const getEffectiveProbeIntervalSeconds = (settings: Settings): number => { const configured = settings.nip66?.probeIntervalSeconds ?? DEFAULT_PROBE_INTERVAL_SECONDS @@ -12,3 +13,11 @@ export const getEffectiveProbeIntervalSeconds = (settings: Settings): number => export const getProbeIntervalMs = (settings: Settings): number => { return getEffectiveProbeIntervalSeconds(settings) * 1000 } + +export const getNip66HistoryRetention = (settings: Settings) => { + const maxAgeHours = settings.nip66?.historyRetentionHours + return { + maxRuns: settings.nip66?.historyRetentionRuns ?? DEFAULT_HISTORY_RETENTION_RUNS, + maxAgeHours: typeof maxAgeHours === 'number' && maxAgeHours > 0 ? maxAgeHours : undefined, + } +} diff --git a/src/utils/nip77.ts b/src/utils/nip77.ts new file mode 100644 index 000000000..3bdd670f1 --- /dev/null +++ b/src/utils/nip77.ts @@ -0,0 +1,21 @@ +import { EventStoreBackend } from '../constants/base' +import { getEventStoreBackend } from './event-store' +import { Settings } from '../@types/settings' + +export const DEFAULT_MAX_EVENTS_PER_SESSION = 100000 +export const DEFAULT_MAX_SESSIONS_PER_CONNECTION = 3 +export const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 60000 + +// Binary size of one negentropy message we send, before hex encoding. Larger answers are cut short +// and finished over further rounds. 60 kB matches strfry's default. +export const NEGENTROPY_FRAME_SIZE_LIMIT = 60000 + +// NIP-77 is served natively only from PostgreSQL; other backends (e.g. strfry) bring their own. +export const isNip77Active = (settings: Settings): boolean => + settings.nip77?.enabled === true && getEventStoreBackend(settings) === EventStoreBackend.POSTGRES + +export const getNip77Limits = (settings: Settings) => ({ + maxEventsPerSession: settings.nip77?.maxEventsPerSession ?? DEFAULT_MAX_EVENTS_PER_SESSION, + maxSessionsPerConnection: settings.nip77?.maxSessionsPerConnection ?? DEFAULT_MAX_SESSIONS_PER_CONNECTION, + sessionIdleTimeoutMs: settings.nip77?.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS, +}) diff --git a/src/utils/operator-notification-enqueue.ts b/src/utils/operator-notification-enqueue.ts index f570153aa..bfc209266 100644 --- a/src/utils/operator-notification-enqueue.ts +++ b/src/utils/operator-notification-enqueue.ts @@ -22,10 +22,12 @@ export const enqueueOperatorNotification = async ( eventType: OperatorNotificationEventType | string, payload: NotificationOutboxPayload, client?: DatabaseClient, -): Promise => { +): Promise => { try { await getOutboxRepository().enqueue(eventType, payload, client) + return true } catch (error) { logger.error('Unable to enqueue operator notification %s', eventType, error) + return false } } diff --git a/src/utils/ops-trust-notifications.ts b/src/utils/ops-trust-notifications.ts new file mode 100644 index 000000000..313601464 --- /dev/null +++ b/src/utils/ops-trust-notifications.ts @@ -0,0 +1,151 @@ +import { ICacheAdapter } from '../@types/adapters' +import { OperatorNotificationEventType } from '../@types/operator-notifications' +import { RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot' +import { createLogger } from '../factories/logger-factory' +import { enqueueOperatorNotification } from './operator-notification-enqueue' + +const logger = createLogger('ops-trust-notifications') + +const PROBE_STATUS_KEY = 'admin:trust:probe:last_status' +const SSL_ALERT_PREFIX = 'admin:trust:ssl:alert:' + +const SSL_EXPIRY_WARNING_DAYS = 14 +const DEDUPE_TTL_SECONDS = 6 * 3600 +const PROBE_STATUS_TTL_SECONDS = 86400 * 30 + +/** Atomically read previous probe status, persist the new status, return alert action. */ +const PROBE_TRANSITION_SCRIPT = ` +local prev = redis.call('GET', KEYS[1]) or '' +local current = ARGV[1] +local ttl = tonumber(ARGV[2]) +local action = '' +if prev ~= '' and (prev == 'failed' or prev == 'partial') and current == 'ok' then + action = 'recovered' +elseif current ~= 'ok' and prev ~= current then + action = 'failed' +end +redis.call('SET', KEYS[1], current, 'EX', ttl) +return action +` + +const enqueueTrustNotification = async ( + eventType: OperatorNotificationEventType, + payload: Record, +): Promise => { + return enqueueOperatorNotification(eventType, payload) +} + +const sendDedupedTrustNotification = async ( + cache: ICacheAdapter, + dedupeKey: string, + eventType: OperatorNotificationEventType, + payload: Record, +): Promise => { + let claimed = false + try { + claimed = await cache.setKeyIfNotExists(dedupeKey, 'pending', DEDUPE_TTL_SECONDS) + if (!claimed) { + return + } + + const enqueued = await enqueueTrustNotification(eventType, payload) + if (!enqueued) { + await cache.deleteKey(dedupeKey) + return + } + + await cache.setKey(dedupeKey, '1', DEDUPE_TTL_SECONDS) + } catch (error) { + if (claimed) { + try { + await cache.deleteKey(dedupeKey) + } catch { + // best-effort release so a later attempt can retry + } + } + throw error + } +} + +export const notifyProbeRunTransition = async ( + cache: ICacheAdapter | undefined, + snapshot: RelayProbeRunSnapshot, +): Promise => { + if (!cache) { + return + } + + try { + const action = String( + (await cache.eval(PROBE_TRANSITION_SCRIPT, [PROBE_STATUS_KEY], [ + snapshot.status, + String(PROBE_STATUS_TTL_SECONDS), + ])) ?? '', + ) + + if (action === 'recovered') { + const enqueued = await enqueueTrustNotification(OperatorNotificationEventType.PROBE_RECOVERED, { + runAt: snapshot.runAt, + status: snapshot.status, + targets: snapshot.targets, + }) + if (!enqueued) { + logger.error('probe recovered notification was not enqueued') + } + } else if (action === 'failed') { + const enqueued = await enqueueTrustNotification(OperatorNotificationEventType.PROBE_FAILED, { + runAt: snapshot.runAt, + status: snapshot.status, + targets: snapshot.targets, + }) + if (!enqueued) { + logger.error('probe failed notification was not enqueued') + } + } + } catch (error) { + logger.error('probe transition notification failed: %o', error) + } + + try { + await notifySslExpiringFromSnapshot(cache, snapshot) + } catch (error) { + logger.error('ssl expiring notifications failed: %o', error) + } +} + +const notifySslExpiringFromSnapshot = async ( + cache: ICacheAdapter, + snapshot: RelayProbeRunSnapshot, +): Promise => { + const warningThresholdMs = SSL_EXPIRY_WARNING_DAYS * 86400 * 1000 + const now = Date.now() + + for (const result of snapshot.results) { + const relayUrl = result.target.relayUrl + const expiresAt = result.tls.data?.expiresAt + if (!relayUrl || !expiresAt) { + continue + } + + const expiryMs = new Date(expiresAt).getTime() + if (!Number.isFinite(expiryMs) || expiryMs <= now) { + continue + } + + const remainingMs = expiryMs - now + if (remainingMs > warningThresholdMs) { + continue + } + + const dedupeKey = `${SSL_ALERT_PREFIX}${relayUrl}` + try { + await sendDedupedTrustNotification(cache, dedupeKey, OperatorNotificationEventType.SSL_EXPIRING, { + relayUrl, + expiresAt, + daysRemaining: Math.ceil(remainingMs / 86400000), + }) + } catch (error) { + logger.error('ssl expiring notification failed for %s: %o', relayUrl, error) + } + } +} diff --git a/src/utils/peering-auth.ts b/src/utils/peering-auth.ts new file mode 100644 index 000000000..623eb437a --- /dev/null +++ b/src/utils/peering-auth.ts @@ -0,0 +1,211 @@ +import WebSocket, { RawData } from 'ws' + +import { Tag } from '../@types/base' +import { Event, UnidentifiedEvent } from '../@types/event' +import { PeeringPeer, Settings } from '../@types/settings' +import { EventKinds, EventTags } from '../constants/base' +import { getPublicKey, identifyEvent, signEvent } from './event' +import { parseRelayPubkey } from './nip43-invites' + +export const PEERING_AUTH_TIMEOUT_MS = 10_000 + +export interface PeeringAuthEventOptions { + relayUrl: string + challenge: string + privateKey: string + createdAt?: number +} + +export interface AuthenticatePeerOptions { + relayUrl: string + privateKey: string + timeoutMs?: number + createdAt?: number +} + +export interface AuthenticatedPeerConnection { + /** Open and authenticated socket; the caller owns it from here. */ + socket: WebSocket + challenge: string + authEvent: Event +} + +/** + * Normalizes a peer pubkey to lowercase hex. Returns undefined for anything + * that is not a 32-byte hex pubkey or an npub, so malformed configured entries + * are skipped rather than throwing during AUTH. + */ +export const parsePeeringPeerPubkey = (value: unknown): string | undefined => { + if (typeof value !== 'string' || value.length === 0) { + return undefined + } + + try { + return parseRelayPubkey(value) + } catch { + return undefined + } +} + +export const findConfiguredPeeringPeer = ( + settings: Pick | undefined, + pubkey: string, +): PeeringPeer | undefined => { + const normalized = parsePeeringPeerPubkey(pubkey) + if (!normalized) { + return undefined + } + + return settings?.peering?.peers?.find( + (peer) => peer.enabled !== false && parsePeeringPeerPubkey(peer.pubkey) === normalized, + ) +} + +/** True only when `pubkey` is an enabled entry in the configured peer list. */ +export const isConfiguredPeeringPeer = (settings: Pick | undefined, pubkey: string): boolean => + typeof findConfiguredPeeringPeer(settings, pubkey) !== 'undefined' + +/** + * Builds the NIP-42 AUTH event that proves this relay's own identity to a + * source relay. Signed with the relay keypair, so the event's pubkey is the + * derived key, not whatever `info.self` happens to say. + */ +export const buildPeeringAuthEvent = async ({ + relayUrl, + challenge, + privateKey, + createdAt = Math.floor(Date.now() / 1000), +}: PeeringAuthEventOptions): Promise => { + if (!relayUrl.trim()) { + throw new Error('relayUrl is required to build a peering AUTH event') + } + + if (!challenge.trim()) { + throw new Error('challenge is required to build a peering AUTH event') + } + + const unidentified: UnidentifiedEvent = { + pubkey: getPublicKey(privateKey), + created_at: createdAt, + kind: EventKinds.AUTH, + tags: [ + [EventTags.AuthRelay, relayUrl], + [EventTags.Challenge, challenge], + ] as Tag[], + content: '', + } + + return signEvent(privateKey)(await identifyEvent(unidentified)) +} + +const closeQuietly = (socket: WebSocket): void => { + try { + socket.close() + } catch { + // The socket may already be closed; there is nothing left to clean up. + } +} + +/** + * Connects to a peer relay and performs NIP-42 AUTH with this relay's own + * keypair. Resolves only after the peer answers `OK true` for the AUTH event, + * leaving the socket open for the caller (a live feed, negentropy session, ...). + */ +export const authenticateToPeeringRelay = (options: AuthenticatePeerOptions): Promise => { + const { relayUrl, privateKey, timeoutMs = PEERING_AUTH_TIMEOUT_MS, createdAt } = options + + return new Promise((resolve, reject) => { + let socket: WebSocket + try { + socket = new WebSocket(relayUrl) + } catch (error) { + reject(error instanceof Error ? error : new Error('Unable to open the peering socket')) + return + } + + let challenge: string | undefined + let authEvent: Event | undefined + let settled = false + let timer: NodeJS.Timeout | undefined + + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + socket.off('message', onMessage) + socket.off('close', onClose) + socket.off('error', onError) + + if (error) { + // A close during the handshake can still emit an `error` later; keep a + // no-op listener so an abandoned connect cannot crash the process. + socket.on('error', () => {}) + closeQuietly(socket) + reject(error) + return + } + + resolve({ socket, challenge: challenge as string, authEvent: authEvent as Event }) + } + + const onMessage = (data: RawData): void => { + let message: unknown + try { + message = JSON.parse(data.toString()) + } catch { + return + } + + if (!Array.isArray(message)) { + return + } + + // The relay sends its challenge as soon as the socket opens. + if (message[0] === 'AUTH' && typeof message[1] === 'string' && challenge === undefined) { + challenge = message[1] + void buildPeeringAuthEvent({ relayUrl, challenge, privateKey, createdAt }).then( + (event) => { + if (settled) { + return + } + authEvent = event + socket.send(JSON.stringify(['AUTH', event])) + }, + (error) => finish(error instanceof Error ? error : new Error('Unable to sign peering AUTH event')), + ) + return + } + + if (message[0] === 'OK' && authEvent !== undefined && message[1] === authEvent.id) { + if (message[2] === true) { + finish() + return + } + + const reason = typeof message[3] === 'string' && message[3] ? `: ${message[3]}` : '' + finish(new Error(`peering AUTH rejected${reason}`)) + } + } + + const onClose = (): void => { + finish(new Error('connection closed before peering AUTH completed')) + } + + const onError = (error: Error): void => { + finish(error) + } + + timer = setTimeout(() => { + finish(new Error('timed out waiting for peering AUTH')) + }, timeoutMs) + timer.unref() + + socket.on('message', onMessage) + socket.on('close', onClose) + socket.on('error', onError) + }) +} diff --git a/src/utils/record-admin-audit.ts b/src/utils/record-admin-audit.ts new file mode 100644 index 000000000..8c6cefafc --- /dev/null +++ b/src/utils/record-admin-audit.ts @@ -0,0 +1,32 @@ +import { Request } from 'express' + +import { RedisAdapter } from '../adapters/redis-adapter' +import { AdminAuditAction } from '../@types/admin-audit' +import { getCacheClient, isCacheConfigured } from '../cache/client' +import { appendAdminAuditEntrySafely } from './admin-audit-store' + +const getAdminAuditCache = (): RedisAdapter | undefined => { + if (!isCacheConfigured()) { + return undefined + } + + return new RedisAdapter(getCacheClient()) +} + +export const recordAdminAuditFromRequest = async ( + action: AdminAuditAction, + request: Request, + detail?: Record, +): Promise => { + await appendAdminAuditEntrySafely(getAdminAuditCache(), action, { + remoteAddress: request.ip, + ...(detail ? { detail } : {}), + }) +} + +export const recordAdminAudit = async ( + action: AdminAuditAction, + fields: { remoteAddress?: string; detail?: Record } = {}, +): Promise => { + await appendAdminAuditEntrySafely(getAdminAuditCache(), action, fields) +} diff --git a/src/utils/relay-probe-history.ts b/src/utils/relay-probe-history.ts new file mode 100644 index 000000000..30e4db54f --- /dev/null +++ b/src/utils/relay-probe-history.ts @@ -0,0 +1,57 @@ +import { ICacheAdapter } from '../@types/adapters' +import { RelayProbeHistoryRetention, RelayProbeRunSnapshot } from '../@types/relay-probe-snapshot' + +export const RELAY_PROBE_HISTORY_KEY = 'nip66:snapshot:history' +export const MAX_PROBE_HISTORY_API_LIMIT = 500 + +const jsonReplacer = (_key: string, value: unknown): unknown => { + if (value instanceof Date) { + return value.toISOString() + } + + return value +} + +export class RelayProbeHistoryStore { + public constructor(private readonly cache: ICacheAdapter) {} + + public async append(snapshot: RelayProbeRunSnapshot, retention: RelayProbeHistoryRetention): Promise { + const score = Date.parse(snapshot.runAt) + if (!Number.isFinite(score)) { + return + } + + const payload = JSON.stringify(snapshot, jsonReplacer) + await this.cache.addToSortedSet(RELAY_PROBE_HISTORY_KEY, { [payload]: String(score) }) + await this.trim(retention) + } + + public async list(limit: number, retention?: RelayProbeHistoryRetention): Promise { + if (retention) { + await this.trim(retention) + } + + const clamped = Math.min(Math.max(Math.floor(limit), 1), MAX_PROBE_HISTORY_API_LIMIT) + const raw = await this.cache.getRangeFromSortedSet(RELAY_PROBE_HISTORY_KEY, -clamped, -1) + + return raw + .map((entry) => JSON.parse(entry) as RelayProbeRunSnapshot) + .reverse() + } + + private async trim(retention: RelayProbeHistoryRetention): Promise { + const maxRuns = Math.max(1, Math.floor(retention.maxRuns)) + // Drop oldest ranks without ZCARD: safe under concurrent trim (idempotent excess removal). + await this.cache.removeRangeByRankFromSortedSet( + RELAY_PROBE_HISTORY_KEY, + 0, + -(maxRuns + 1), + ) + + const maxAgeHours = retention.maxAgeHours + if (typeof maxAgeHours === 'number' && maxAgeHours > 0) { + const minScore = Date.now() - maxAgeHours * 3600 * 1000 + await this.cache.removeRangeByScoreFromSortedSet(RELAY_PROBE_HISTORY_KEY, 0, minScore) + } + } +} diff --git a/src/utils/relay-probe-targets.ts b/src/utils/relay-probe-targets.ts index 9212533d0..66de9898e 100644 --- a/src/utils/relay-probe-targets.ts +++ b/src/utils/relay-probe-targets.ts @@ -25,7 +25,7 @@ const addUniqueTarget = (targets: string[], seen: Set, candidate: string targets.push(trimmed) } -/** Targets eligible for public kind 30166 publish (never includes mirroring.static peers). */ +/** Targets eligible for public kind 30166 publish. */ export const resolvePublicProbeTargets = (settings: Settings): string[] => { const targets: string[] = [] const seen = new Set() @@ -56,25 +56,8 @@ export const resolvePublicProbeTargetKeys = (settings: Settings): Set => return keys } -/** All probe targets: public targets plus configured static mirrors (operator snapshot only). */ -export const resolveProbeTargets = (settings: Settings): string[] => { - const targets = resolvePublicProbeTargets(settings) - const seen = new Set() - - for (const target of targets) { - try { - seen.add(normalizeRelayUrlForDTag(target)) - } catch { - seen.add(target.toLowerCase()) - } - } - - for (const mirror of settings.mirroring?.static ?? []) { - addUniqueTarget(targets, seen, mirror.address) - } - - return targets -} +/** All probe targets. */ +export const resolveProbeTargets = (settings: Settings): string[] => resolvePublicProbeTargets(settings) export const filterValidProbeTargets = (targets: string[]): { valid: string[]; invalid: string[] } => { const valid: string[] = [] diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index 93fb91498..cdf8c68b9 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -1,11 +1,14 @@ import fs from 'fs' +import { setTimeout as delay } from 'node:timers/promises' import { join } from 'path' import yaml from 'js-yaml' import { isNil, mergeDeepRight } from 'ramda' import { createLogger } from '../factories/logger-factory' import { EVENT_STORE_BACKENDS, isEventStoreBackend } from './event-store' +import { GRANDFATHERED_PLAN_ID } from '../@types/user-subscription' import { Settings } from '../@types/settings' +import { parsePeeringPeerPubkey } from './peering-auth' import { getConfigBaseDir, getDefaultSettingsFilePath, @@ -13,6 +16,7 @@ import { getSettingsAuditLogPath, getSettingsBackupDir, getSettingsFilePath, + getSettingsWriteLockPath, } from './settings-paths' export { @@ -22,8 +26,149 @@ export { getSettingsAuditLogPath, getSettingsBackupDir, getSettingsFilePath, + getSettingsWriteLockPath, } from './settings-paths' +const SETTINGS_WRITE_LOCK_STALE_MS = 60_000 +const SETTINGS_WRITE_LOCK_RETRY_MS = 25 +const SETTINGS_WRITE_LOCK_MAX_WAIT_MS = 30_000 + +type SettingsWriteLockMetadata = { + pid: number + acquiredAt: number +} + +const readSettingsWriteLockMetadata = (lockPath: string): SettingsWriteLockMetadata | undefined => { + try { + const raw = fs.readFileSync(lockPath, 'utf-8') + const [pidLine, acquiredLine] = raw.split('\n') + const pid = Number(pidLine) + const acquiredAt = Number(acquiredLine) + if (!Number.isFinite(pid) || !Number.isFinite(acquiredAt)) { + return undefined + } + return { pid, acquiredAt } + } catch { + return undefined + } +} + +const isSettingsWriteLockStale = (metadata: SettingsWriteLockMetadata): boolean => { + if (Date.now() - metadata.acquiredAt > SETTINGS_WRITE_LOCK_STALE_MS) { + return true + } + + try { + process.kill(metadata.pid, 0) + return false + } catch (error) { + // Only ESRCH proves the owner is gone. EPERM means the process exists under + // another user, and any other error is treated as alive so a failed check + // can never steal a live lock; the age rule above remains the fallback. + return (error as NodeJS.ErrnoException).code === 'ESRCH' + } +} + +const reclaimSettingsWriteLock = (lockPath: string): void => { + const reclaimPath = `${lockPath}.reclaim.${process.pid}.${Date.now()}` + fs.renameSync(lockPath, reclaimPath) + fs.rmSync(reclaimPath, { force: true }) +} + +const attemptStealStaleSettingsWriteLock = ( + lockPath: string, + expected: SettingsWriteLockMetadata, +): boolean => { + try { + const current = readSettingsWriteLockMetadata(lockPath) + if ( + !current || + current.pid !== expected.pid || + current.acquiredAt !== expected.acquiredAt || + !isSettingsWriteLockStale(current) + ) { + return false + } + + reclaimSettingsWriteLock(lockPath) + return true + } catch { + return false + } +} + +// A writer holds the lock between openSync and writeFileSync, so a lock with +// missing or unparseable metadata is only abandoned once it has been untouched +// for the same grace period as a stale lock. Reclaiming it immediately would +// race a live writer. +const attemptStealAbandonedSettingsWriteLock = (lockPath: string): boolean => { + try { + const stats = fs.statSync(lockPath) + if (Date.now() - stats.mtimeMs <= SETTINGS_WRITE_LOCK_STALE_MS) { + return false + } + + reclaimSettingsWriteLock(lockPath) + return true + } catch { + return false + } +} + +export const withSettingsWriteLock = async (operation: () => T): Promise => { + ensureSettingsExists() + const lockPath = getSettingsWriteLockPath() + const deadline = Date.now() + SETTINGS_WRITE_LOCK_MAX_WAIT_MS + let lockFd: number | undefined + + while (Date.now() < deadline) { + try { + const lockFdLocal = fs.openSync(lockPath, 'wx') + try { + fs.writeFileSync(lockFdLocal, `${process.pid}\n${Date.now()}\n`) + } catch (error) { + // The lock exists before it has contents; leaving that empty file + // behind would block every later writer until someone removes it. + try { + fs.closeSync(lockFdLocal) + } finally { + fs.rmSync(lockPath, { force: true }) + } + throw error + } + lockFd = lockFdLocal + break + } catch (error) { + const errno = error as NodeJS.ErrnoException + if (errno.code !== 'EEXIST') { + throw error + } + + const metadata = readSettingsWriteLockMetadata(lockPath) + if (metadata) { + if (isSettingsWriteLockStale(metadata)) { + attemptStealStaleSettingsWriteLock(lockPath, metadata) + } + } else { + attemptStealAbandonedSettingsWriteLock(lockPath) + } + + await delay(SETTINGS_WRITE_LOCK_RETRY_MS) + } + } + + if (lockFd === undefined) { + throw new Error('Timed out waiting for settings write lock') + } + + try { + return operation() + } finally { + fs.closeSync(lockFd) + fs.rmSync(lockPath, { force: true }) + } +} + const logger = createLogger('settings-config') export type ValidationIssue = { @@ -61,6 +206,12 @@ const assertSafePathKey = (key: string, segment: string): void => { } } +/** Settings that no longer do anything because the static mirroring worker was removed. */ +const REMOVED_SETTINGS = [ + { path: '$', key: 'mirroring' }, + { path: 'admin.notifications.events', key: 'mirror.disconnected' }, +] + const hasOwn = (object: Record, key: string): boolean => { return Object.prototype.hasOwnProperty.call(object, key) } @@ -234,6 +385,11 @@ const validateShape = (schema: unknown, candidate: unknown, path: PathToken[], i continue } + // Settings that were removed stay accepted, so a settings file that still has one can be saved. + if (REMOVED_SETTINGS.some((removed) => removed.path === renderedPath && removed.key === key)) { + continue + } + if (!hasOwn(schema, key)) { issues.push({ path: formatPathTokens([...path, { type: 'key', key }]), @@ -583,17 +739,378 @@ const validateRateLimitPeriods = ( issues.push({ path: `${pathPrefix}[${index}]`, message: 'rateLimits entry must be an object' }) return } - if (!(entry.period > 0)) { - issues.push({ path: `${pathPrefix}[${index}].period`, message: 'period must be greater than 0' }) + // The window is in milliseconds, and 60 is as valid a number as 60000, so + // the unit cannot be inferred here: it is stated on RateLimit and on each + // block in resources/default-settings.yaml. What this can refuse is a value + // that is not a whole count of milliseconds at all. + if (typeof entry.period !== 'number' || !Number.isFinite(entry.period)) { + issues.push({ path: `${pathPrefix}[${index}].period`, message: 'period must be a number of milliseconds' }) + } else if (!Number.isInteger(entry.period)) { + issues.push({ path: `${pathPrefix}[${index}].period`, message: 'period must be a whole number of milliseconds' }) + } else if (entry.period < 1) { + issues.push({ path: `${pathPrefix}[${index}].period`, message: 'period must be at least 1 millisecond' }) + } + }) +} + +const isPositiveInteger = (value: unknown): boolean => + (typeof value === 'number' || typeof value === 'bigint') && Number.isInteger(Number(value)) && Number(value) > 0 + +// Unlike the global limits, a plan's limits are not covered by the settings schema while the plan list +// is empty by default, so each entry is checked here. +const validatePlanRateLimits = (rateLimits: unknown, path: string, issues: ValidationIssue[]): void => { + if (rateLimits === undefined || rateLimits === null) { + return + } + if (!Array.isArray(rateLimits)) { + issues.push({ path, message: 'rateLimits must be a list' }) + return + } + + rateLimits.forEach((entry, index) => { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + issues.push({ path: `${path}[${index}]`, message: 'rateLimits entry must be an object' }) + return + } + for (const key of ['period', 'rate']) { + if (!isPositiveInteger(entry[key])) { + issues.push({ path: `${path}[${index}].${key}`, message: `${key} must be a positive integer` }) + } + } + }) +} + +const validateSubscriptionPlans = (plans: unknown, issues: ValidationIssue[]): void => { + // A value that is not a list is already reported against the default settings schema. + if (!Array.isArray(plans)) { + return + } + + const ids = new Set() + const amounts = new Set() + + plans.forEach((plan, index) => { + const prefix = `payments.subscriptionPlans[${index}]` + if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) { + issues.push({ path: prefix, message: 'plan must be an object' }) + return + } + + if (typeof plan.id !== 'string' || !plan.id.trim()) { + issues.push({ path: `${prefix}.id`, message: 'id is required' }) + } else if (plan.id === GRANDFATHERED_PLAN_ID) { + issues.push({ path: `${prefix}.id`, message: `id "${GRANDFATHERED_PLAN_ID}" is reserved` }) + } else if (ids.has(plan.id)) { + issues.push({ path: `${prefix}.id`, message: `id "${plan.id}" is used by more than one plan` }) + } else { + ids.add(plan.id) + } + + // The amount paid selects the plan, so two plans with one price could not be told apart. + if (!isPositiveInteger(plan.amount)) { + issues.push({ path: `${prefix}.amount`, message: 'amount must be a positive integer (msats)' }) + } else if (amounts.has(String(plan.amount))) { + issues.push({ path: `${prefix}.amount`, message: `amount ${plan.amount} is used by more than one plan` }) + } else { + amounts.add(String(plan.amount)) + } + + if (!isPositiveInteger(plan.intervalSeconds)) { + issues.push({ path: `${prefix}.intervalSeconds`, message: 'intervalSeconds must be a positive integer' }) + } + + if (!(Number.isInteger(plan.graceSeconds) && plan.graceSeconds >= 0)) { + issues.push({ path: `${prefix}.graceSeconds`, message: 'graceSeconds must be a non-negative integer' }) + } + + const limits = plan.limits + if (limits !== undefined && limits !== null) { + if (typeof limits !== 'object' || Array.isArray(limits)) { + issues.push({ path: `${prefix}.limits`, message: 'limits must be an object' }) + return + } + validatePlanRateLimits(limits.rateLimits, `${prefix}.limits.rateLimits`, issues) + if (limits.retentionMaxDays !== undefined && !isPositiveInteger(limits.retentionMaxDays)) { + issues.push({ + path: `${prefix}.limits.retentionMaxDays`, + message: 'retentionMaxDays must be a positive integer', + }) + } } }) } +const PEERING_KIND_MAX = 65535 +const PEERING_POW_MAX_BITS = 256 +const PEERING_SCOPE_KEYS = new Set(['kinds', 'since', 'until', 'windowSeconds']) +const PEERING_TERM_KEYS = new Set(['amount', 'unit', 'period']) +const PEERING_PEER_KEYS = new Set(['pubkey', 'relayUrl', 'enabled']) + +const isValidPeeringKindValue = (value: unknown): boolean => + typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= PEERING_KIND_MAX + +const validatePeeringKinds = (kinds: unknown, path: string, issues: ValidationIssue[]): void => { + if (!Array.isArray(kinds) || kinds.length === 0) { + issues.push({ + path, + message: `must be a non-empty list of event kinds or [min, max] ranges`, + }) + return + } + + kinds.forEach((kind, index) => { + if (Array.isArray(kind)) { + if (kind.length !== 2 || !isValidPeeringKindValue(kind[0]) || !isValidPeeringKindValue(kind[1])) { + issues.push({ + path: `${path}[${index}]`, + message: `range must contain two integers between 0 and ${PEERING_KIND_MAX}`, + }) + return + } + + if ((kind[0] as number) > (kind[1] as number)) { + issues.push({ path: `${path}[${index}]`, message: 'range start must be less than or equal to range end' }) + } + return + } + + if (!isValidPeeringKindValue(kind)) { + issues.push({ path: `${path}[${index}]`, message: `must be an integer between 0 and ${PEERING_KIND_MAX}` }) + } + }) +} + +// Scopes and terms default to empty lists, so the default settings schema has no +// entry shape to check them against. Validate each entry here instead. +const validatePeeringScopes = (scopes: unknown, issues: ValidationIssue[]): void => { + if (!Array.isArray(scopes)) { + return + } + + scopes.forEach((scope, index) => { + const prefix = `peering.scopes[${index}]` + + if (!isPlainObject(scope)) { + issues.push({ path: prefix, message: 'scope must be an object' }) + return + } + + for (const key of Object.keys(scope)) { + if (!PEERING_SCOPE_KEYS.has(key)) { + issues.push({ path: `${prefix}.${key}`, message: 'Unknown setting key' }) + } + } + + validatePeeringKinds(scope.kinds, `${prefix}.kinds`, issues) + + const { since, until, windowSeconds } = scope + + if (since !== undefined && !(typeof since === 'number' && Number.isSafeInteger(since) && since >= 0)) { + issues.push({ path: `${prefix}.since`, message: 'since must be a non-negative integer (unix seconds)' }) + } + + if (until !== undefined && !(typeof until === 'number' && Number.isSafeInteger(until) && until >= 0)) { + issues.push({ path: `${prefix}.until`, message: 'until must be a non-negative integer (unix seconds)' }) + } + + if ( + windowSeconds !== undefined && + !(typeof windowSeconds === 'number' && Number.isSafeInteger(windowSeconds) && windowSeconds > 0) + ) { + issues.push({ path: `${prefix}.windowSeconds`, message: 'windowSeconds must be a positive integer (seconds)' }) + } else if (windowSeconds !== undefined && (since !== undefined || until !== undefined)) { + issues.push({ path: prefix, message: 'windowSeconds cannot be combined with since or until' }) + } + + if ( + typeof since === 'number' && + typeof until === 'number' && + Number.isSafeInteger(since) && + Number.isSafeInteger(until) && + since > until + ) { + issues.push({ path: prefix, message: 'since must be less than or equal to until' }) + } + }) +} + +const validatePeeringTerms = (terms: unknown, issues: ValidationIssue[]): void => { + if (!Array.isArray(terms)) { + return + } + + terms.forEach((term, index) => { + const prefix = `peering.terms[${index}]` + + if (!isPlainObject(term)) { + issues.push({ path: prefix, message: 'term must be an object' }) + return + } + + for (const key of Object.keys(term)) { + if (!PEERING_TERM_KEYS.has(key)) { + issues.push({ path: `${prefix}.${key}`, message: 'Unknown setting key' }) + } + } + + if (!(typeof term.amount === 'number' && Number.isSafeInteger(term.amount) && term.amount >= 0)) { + issues.push({ path: `${prefix}.amount`, message: 'amount must be a non-negative integer' }) + } + + if (typeof term.unit !== 'string' || !term.unit.trim()) { + issues.push({ path: `${prefix}.unit`, message: 'unit must be a non-empty string' }) + } + + if (!(typeof term.period === 'number' && Number.isSafeInteger(term.period) && term.period > 0)) { + issues.push({ path: `${prefix}.period`, message: 'period must be a positive integer (seconds)' }) + } + }) +} + +const validatePeeringPeers = (peers: unknown, issues: ValidationIssue[]): void => { + if (!Array.isArray(peers)) { + return + } + + const seenPubkeys = new Set() + + peers.forEach((peer, index) => { + const prefix = `peering.peers[${index}]` + + if (!isPlainObject(peer)) { + issues.push({ path: prefix, message: 'peer must be an object' }) + return + } + + for (const key of Object.keys(peer)) { + if (!PEERING_PEER_KEYS.has(key)) { + issues.push({ path: `${prefix}.${key}`, message: 'Unknown setting key' }) + } + } + + const pubkey = parsePeeringPeerPubkey(peer.pubkey) + if (typeof peer.pubkey !== 'string' || !peer.pubkey.trim()) { + issues.push({ path: `${prefix}.pubkey`, message: 'pubkey is required' }) + } else if (!pubkey) { + issues.push({ path: `${prefix}.pubkey`, message: 'must be a 32-byte hex pubkey or npub' }) + } else if (seenPubkeys.has(pubkey)) { + issues.push({ path: `${prefix}.pubkey`, message: 'pubkey must be unique' }) + } else { + seenPubkeys.add(pubkey) + } + + const relayUrl = typeof peer.relayUrl === 'string' ? peer.relayUrl.trim() : '' + if (!relayUrl) { + issues.push({ path: `${prefix}.relayUrl`, message: 'relayUrl is required' }) + } else { + try { + const parsed = new URL(relayUrl) + if (!['ws:', 'wss:'].includes(parsed.protocol) || !parsed.host) { + issues.push({ path: `${prefix}.relayUrl`, message: 'must be a ws:// or wss:// URL' }) + } + } catch { + issues.push({ path: `${prefix}.relayUrl`, message: 'must be a valid ws:// or wss:// URL' }) + } + } + + if (peer.enabled !== undefined && typeof peer.enabled !== 'boolean') { + issues.push({ path: `${prefix}.enabled`, message: 'must be a boolean' }) + } + }) +} + +const validatePeering = (settings: Settings, issues: ValidationIssue[]): void => { + const peering = settings.peering + + if (peering === undefined || peering === null) { + return + } + + // A non-object value is already reported against the default settings schema. + if (!isPlainObject(peering)) { + return + } + + const enabled = peering.enabled === true + + if (peering.minRequestPowDifficulty !== undefined) { + const bits = peering.minRequestPowDifficulty + if ( + typeof bits !== 'number' || + !Number.isInteger(bits) || + bits < 0 || + bits > PEERING_POW_MAX_BITS + ) { + issues.push({ + path: 'peering.minRequestPowDifficulty', + message: `must be an integer between 0 and ${PEERING_POW_MAX_BITS}`, + }) + } + } + + const endpoint = typeof peering.endpoint === 'string' ? peering.endpoint.trim() : '' + if (endpoint) { + try { + const parsed = new URL(endpoint) + if (!['ws:', 'wss:'].includes(parsed.protocol) || !parsed.host) { + issues.push({ path: 'peering.endpoint', message: 'must be a ws:// or wss:// URL' }) + } + } catch { + issues.push({ path: 'peering.endpoint', message: 'must be a valid ws:// or wss:// URL' }) + } + } + + // NIP-11 calls `contact` a URI. Reject free text so a relay does not publish + // an address other relays cannot use to reach the operator. + const contact = typeof peering.contact === 'string' ? peering.contact.trim() : '' + if (contact) { + try { + new URL(contact) + } catch { + issues.push({ + path: 'peering.contact', + message: 'must be a valid URI (for example mailto: or https:)', + }) + } + } + + validatePeeringScopes(peering.scopes, issues) + validatePeeringTerms(peering.terms, issues) + validatePeeringPeers(peering.peers, issues) + + if (!enabled) { + return + } + + if (!Array.isArray(peering.scopes) || peering.scopes.length === 0) { + issues.push({ path: 'peering.scopes', message: 'at least one scope is required when peering is enabled' }) + } + + if (!contact && !endpoint) { + issues.push({ path: 'peering.contact', message: 'contact or endpoint is required when peering is enabled' }) + } +} + export const validateSettings = (settings: Settings): ValidationIssue[] => { const issues: ValidationIssue[] = [] if (!settings.info?.relay_url) { issues.push({ path: 'info.relay_url', message: 'relay_url is required' }) + } else if (typeof settings.info.relay_url === 'string') { + try { + const parsed = new URL(settings.info.relay_url) + + if (!parsed.host) { + issues.push({ path: 'info.relay_url', message: 'relay_url must include a valid host' }) + } else if (!['ws:', 'wss:', 'http:', 'https:'].includes(parsed.protocol)) { + issues.push({ + path: 'info.relay_url', + message: 'relay_url must use ws, wss, http, or https protocol', + }) + } + } catch { + issues.push({ path: 'info.relay_url', message: 'relay_url must be a valid absolute URL' }) + } } if (!settings.info?.name) { @@ -621,6 +1138,37 @@ export const validateSettings = (settings: Settings): ValidationIssue[] => { }) } + const nip66 = settings.nip66 + if (nip66?.historyRetentionRuns !== undefined) { + const runs = nip66.historyRetentionRuns + if (!Number.isFinite(runs) || !(runs >= 1)) { + issues.push({ path: 'nip66.historyRetentionRuns', message: 'historyRetentionRuns must be at least 1' }) + } + } + if ( + nip66?.historyRetentionHours !== undefined && + !(nip66.historyRetentionHours === 0 || nip66.historyRetentionHours >= 1) + ) { + issues.push({ + path: 'nip66.historyRetentionHours', + message: 'historyRetentionHours must be 0 (disabled) or at least 1', + }) + } + + const nip77 = settings.nip77 + if (nip77?.enabled) { + for (const key of ['maxEventsPerSession', 'maxSessionsPerConnection', 'sessionIdleTimeoutMs'] as const) { + const value = nip77[key] + // Wrong types (e.g. a string) are already reported against the default settings schema. + if (typeof value === 'number' && !(Number.isInteger(value) && value > 0)) { + issues.push({ path: `nip77.${key}`, message: `${key} must be a positive integer` }) + } + } + } + + validateSubscriptionPlans(settings.payments?.subscriptionPlans, issues) + validatePeering(settings, issues) + const pow = settings.limits?.event?.pow if (pow?.enabled) { if (!(pow.floorBits >= 0) || !(pow.floorBits <= pow.ceilingBits)) { diff --git a/src/utils/settings-guided-schema.ts b/src/utils/settings-guided-schema.ts index 109e5135b..d1a1512bc 100644 --- a/src/utils/settings-guided-schema.ts +++ b/src/utils/settings-guided-schema.ts @@ -35,6 +35,24 @@ export const requireSafeNonNegativeIntegerSettingValue = (value: string): string return result.success ? undefined : result.error.issues[0].message } +const positiveSafeIntegerSettingValueSchema = z + .string() + .trim() + .regex(/^\d+$/, 'Value must be a positive integer') + .transform(Number) + .pipe( + z + .number() + .int() + .min(1, 'Value must be a positive integer') + .max(Number.MAX_SAFE_INTEGER, 'Value must be a safe integer'), + ) + +export const requireSafePositiveIntegerSettingValue = (value: string): string | undefined => { + const result = positiveSafeIntegerSettingValueSchema.safeParse(value) + return result.success ? undefined : result.error.issues[0].message +} + export const guidedSettingCategories: GuidedSettingCategory[] = [ { value: 'relay-info', @@ -254,6 +272,25 @@ export const guidedSettingCategories: GuidedSettingCategory[] = [ path: 'nip56.hideActionableReports', type: 'boolean', }, + { label: 'Enable NIP-77 negentropy sync (PostgreSQL backend only)', path: 'nip77.enabled', type: 'boolean' }, + { + label: 'NIP-77 max events per session', + path: 'nip77.maxEventsPerSession', + type: 'number', + validate: requireSafePositiveIntegerSettingValue, + }, + { + label: 'NIP-77 max sessions per connection', + path: 'nip77.maxSessionsPerConnection', + type: 'number', + validate: requireSafePositiveIntegerSettingValue, + }, + { + label: 'NIP-77 session idle timeout (ms)', + path: 'nip77.sessionIdleTimeoutMs', + type: 'number', + validate: requireSafePositiveIntegerSettingValue, + }, ], }, { diff --git a/src/utils/settings-paths.ts b/src/utils/settings-paths.ts index d55743195..49290bb4f 100644 --- a/src/utils/settings-paths.ts +++ b/src/utils/settings-paths.ts @@ -12,3 +12,5 @@ export const getDefaultSettingsFilePath = (): string => join(process.cwd(), 'res export const getSettingsBackupDir = (): string => join(getConfigBaseDir(), 'backups') export const getSettingsAuditLogPath = (): string => join(getConfigBaseDir(), 'settings-audit.jsonl') + +export const getSettingsWriteLockPath = (): string => join(getConfigBaseDir(), '.settings-write.lock') diff --git a/src/utils/sliding-window-rate-limiter.ts b/src/utils/sliding-window-rate-limiter.ts index c91efae48..e9f8ec609 100644 --- a/src/utils/sliding-window-rate-limiter.ts +++ b/src/utils/sliding-window-rate-limiter.ts @@ -1,4 +1,4 @@ -import { IRateLimiter, IRateLimiterOptions } from '../@types/utils' +import { IRateLimiter, IRateLimiterOptions, IRateLimiterResult } from '../@types/utils' import { createLogger } from '../factories/logger-factory' import { ICacheAdapter } from '../@types/adapters' @@ -50,7 +50,7 @@ export class SlidingWindowRateLimiter implements IRateLimiter { private readonly cache: ICacheAdapter, ) { } - public async hit(key: string, step: number, options: IRateLimiterOptions): Promise { + public async hit(key: string, step: number, options: IRateLimiterOptions): Promise { const timestamp = Date.now() const { period, rate } = options @@ -65,6 +65,13 @@ export class SlidingWindowRateLimiter implements IRateLimiter { logger('hit on %s bucket: is rate limited? %s', key, isRateLimited) - return isRateLimited + if (!isRateLimited) { + return { limited: false } + } + + // The window is the honest upper bound: the oldest hit ages out at most one + // period from now, and reading the sorted set's first score to narrow that + // down is a second round trip on the hot path. + return { limited: true, retryAfterMs: period } } } diff --git a/src/utils/subscription.ts b/src/utils/subscription.ts new file mode 100644 index 000000000..cd445aa51 --- /dev/null +++ b/src/utils/subscription.ts @@ -0,0 +1,100 @@ +import { Pubkey } from '../@types/base' +import { Settings, SubscriptionPlan } from '../@types/settings' +import { GRANDFATHERED_PLAN_ID, SubscriptionStatus, UserSubscription } from '../@types/user-subscription' + +/** How long before a period ends the relay issues the renewal invoice. */ +export const RENEWAL_LEAD_SECONDS = 3 * 24 * 60 * 60 + +export const getSubscriptionPlans = (settings: Settings): SubscriptionPlan[] => + settings.payments?.subscriptionPlans ?? [] + +/** Plans only take effect while payments are on, so an operator can switch them off without editing the list. */ +export const areSubscriptionsEnabled = (settings: Settings): boolean => + settings.payments?.enabled === true && getSubscriptionPlans(settings).length > 0 + +export const findSubscriptionPlan = (settings: Settings, planId: string | null | undefined) => + planId ? getSubscriptionPlans(settings).find((plan) => plan.id === planId) : undefined + +/** + * When access ends, or undefined if it never does. + * + * Judged from the dates rather than the stored status, so a user is not locked out or kept + * in by a sweep that has not run yet. A paid period carries its grace end from the payment, + * so access runs on through grace even before the sweep marks the subscription past due. + */ +export const getAccessEndsAt = (subscription: UserSubscription): Date | undefined => { + if (subscription.status === SubscriptionStatus.EXPIRED) { + return new Date(0) + } + + if (!subscription.currentPeriodEnd) { + return undefined + } + + return subscription.graceEndsAt ?? subscription.currentPeriodEnd +} + +/** + * Users with no row are admitted users from before plans were enabled, or admitted without + * paying (invite code, NIP-43), so they keep access. + */ +export const hasSubscriptionAccess = (subscription: UserSubscription | undefined, now: Date): boolean => { + if (!subscription) { + return true + } + + const endsAt = getAccessEndsAt(subscription) + + return !endsAt || endsAt.getTime() > now.getTime() +} + +/** + * The subscription after a payment for `plan`. A payment made while access is still running + * extends it from the current period end, so paying early costs nothing. After access has + * lapsed, or on the never-ending grandfathered plan, the new period starts now. + */ +export const applyPayment = ( + pubkey: Pubkey, + existing: UserSubscription | undefined, + plan: SubscriptionPlan, + now: Date, +): Omit => { + const stillRunning = + existing && + existing.planId !== GRANDFATHERED_PLAN_ID && + existing.currentPeriodEnd && + (existing.status === SubscriptionStatus.ACTIVE || existing.status === SubscriptionStatus.RENEWAL_PENDING) && + existing.currentPeriodEnd.getTime() > now.getTime() + + const periodStart = stillRunning ? (existing.currentPeriodEnd as Date) : now + const periodEnd = new Date(periodStart.getTime() + plan.intervalSeconds * 1000) + + return { + pubkey, + planId: plan.id, + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: periodStart, + currentPeriodEnd: periodEnd, + graceEndsAt: plan.graceSeconds > 0 ? new Date(periodEnd.getTime() + plan.graceSeconds * 1000) : null, + cancelAtPeriodEnd: false, + } +} + +/** + * Where a subscription whose period has ended goes next: straight to expired if the plan has + * no grace or the grace window has already passed, otherwise into grace. + */ +export const endPeriod = ( + subscription: UserSubscription, + plan: SubscriptionPlan, + now: Date, +): Pick => { + const periodEnd = subscription.currentPeriodEnd as Date + const graceEndsAt = new Date(periodEnd.getTime() + plan.graceSeconds * 1000) + + if (plan.graceSeconds <= 0 || graceEndsAt.getTime() <= now.getTime()) { + return { status: SubscriptionStatus.EXPIRED, graceEndsAt: null } + } + + return { status: SubscriptionStatus.PAST_DUE, graceEndsAt } +} diff --git a/src/utils/transform.ts b/src/utils/transform.ts index da3cfc5e6..a9021037d 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -132,6 +132,9 @@ export const fromDBInvoice = applySpec({ updatedAt: prop('updated_at'), createdAt: prop('created_at'), verifyURL: prop('verify_url'), + planId: prop('plan_id'), + periodStart: prop('period_start'), + periodEnd: prop('period_end'), }) export const fromDBUser = applySpec({ @@ -160,6 +163,28 @@ export const fromBech32 = (input: string) => { ).toString('hex') } +/** + * The hex of an `npub`. Unlike `fromBech32` it takes only the `npub` prefix and rejects a value that is not exactly 32 + * bytes, instead of cutting a longer one down to its first 32. + */ +export const fromNpub = (input: string): string => { + if (input !== input.toLowerCase() && input !== input.toUpperCase()) { + throw new Error('Bech32 mixed-case input is invalid') + } + + const { prefix, words } = bech32Decode(input) + if (prefix !== 'npub') { + throw new Error(`Expected an npub, got ${prefix}`) + } + + const bytes = bech32Convert(words, 5, 8, false) + if (bytes.length !== 32) { + throw new Error(`An npub carries 32 bytes, got ${bytes.length}`) + } + + return Buffer.from(bytes).toString('hex') +} + export const toBech32 = (prefix: string) => (input: string): string => { return bech32Encode(prefix, bech32Convert(Array.from(Buffer.from(input, 'hex')), 8, 5, true)) } diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml index 9baea88a7..7aea5c995 100644 --- a/test/integration/docker-compose.yml +++ b/test/integration/docker-compose.yml @@ -68,7 +68,7 @@ services: timeout: 5s retries: 5 migrations-test: - image: node:24-alpine + image: node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 environment: DB_HOST: db-test DB_PORT: 5432 diff --git a/test/integration/features/nip-11/nip-11.feature b/test/integration/features/nip-11/nip-11.feature index d6972e8ab..ca6a13279 100644 --- a/test/integration/features/nip-11/nip-11.feature +++ b/test/integration/features/nip-11/nip-11.feature @@ -31,6 +31,25 @@ Feature: NIP-11 When a client requests the relay information document Then the limitation object contains a max_filters field + Scenario: Relay information document advertises configured peering terms + When a client requests the relay information document + Then the peering object matches the configured peering settings + + Scenario: Settings validation accepts a complete peering configuration + When peering settings are validated with a complete configuration + Then the peering settings validation reports no issues + + Scenario: Settings validation rejects invalid peering configuration + When peering settings are validated with an invalid configuration + Then the peering settings validation reports an issue at "peering.contact" + And the peering settings validation reports an issue at "peering.endpoint" + And the peering settings validation reports an issue at "peering.minRequestPowDifficulty" + And the peering settings validation reports an issue at "peering.scopes" + And the peering settings validation reports an issue at "peering.scopes[0]" + And the peering settings validation reports an issue at "peering.scopes[0].kinds[0]" + And the peering settings validation reports an issue at "peering.terms[0].amount" + And the peering settings validation reports an issue at "peering.terms[0].period" + Scenario: WebSocket connections coexist with HTTP on the same port Given someone called Alice When Alice sends a text_note event with content "nostr is great" diff --git a/test/integration/features/nip-11/nip-11.feature.ts b/test/integration/features/nip-11/nip-11.feature.ts index dd706f6a0..728a830f4 100644 --- a/test/integration/features/nip-11/nip-11.feature.ts +++ b/test/integration/features/nip-11/nip-11.feature.ts @@ -3,8 +3,10 @@ import axios, { AxiosResponse } from 'axios' import chai from 'chai' import packageJson from '../../../../package.json' +import { Settings } from '../../../../src/@types/settings' import { DEFAULT_FILTER_LIMIT } from '../../../../src/constants/base' import { createSettings } from '../../../../src/factories/settings-factory' +import { validateSettings } from '../../../../src/utils/settings-config' chai.use(require('sinon-chai')) const { expect } = chai @@ -67,9 +69,9 @@ Then('the relay information document contains the required fields', function(thi Then('the supported_nips field matches the NIPs declared in package.json', function(this: World>) { const doc = this.parameters.httpResponse.data - // NIP-43 is only advertised when enabled in settings; the integration - // relay runs with NIP-43 disabled, so it is filtered from the document. - expect(doc.supported_nips).to.deep.equal(packageJson.supportedNips.filter((nip: number) => nip !== 43)) + // NIP-43 and NIP-77 are only advertised when enabled in settings; the integration + // relay runs with both disabled, so they are filtered from the document. + expect(doc.supported_nips).to.deep.equal(packageJson.supportedNips.filter((nip: number) => nip !== 43 && nip !== 77)) }) Then('the response body is not a relay information document', function(this: World>) { @@ -84,6 +86,71 @@ Then('the limitation object contains a max_filters field', function(this: World< expect(doc.limitation.max_filters).to.equal(expectedMaxFilters) }) +Then('the peering object matches the configured peering settings', function(this: World>) { + const doc = this.parameters.httpResponse.data + expect(doc.peering).to.deep.equal({ + enabled: true, + contact: 'mailto:peering@example.com', + endpoint: 'wss://relay.example.com', + min_pow_difficulty: 16, + scopes: [ + { kinds: [1, 6], window_seconds: 3600 }, + { kinds: [3], since: 1700000000 }, + ], + terms: [{ amount: 1000, unit: 'msats', period: 2592000 }], + }) +}) + +const peeringIssuesFor = (peering: unknown) => + validateSettings({ ...createSettings(), peering } as Settings).filter((issue) => issue.path.startsWith('peering')) + +When('peering settings are validated with a complete configuration', function(this: World>) { + this.parameters.peeringValidationIssues = peeringIssuesFor({ + enabled: true, + contact: 'mailto:peering@example.com', + endpoint: 'wss://relay.example.com', + minRequestPowDifficulty: 16, + scopes: [ + { kinds: [1, [10000, 19999]], windowSeconds: 3600 }, + { kinds: [3], since: 1700000000, until: 1800000000 }, + ], + terms: [{ amount: 1000, unit: 'msats', period: 2592000 }], + }) +}) + +When('peering settings are validated with an invalid configuration', function(this: World>) { + this.parameters.peeringValidationIssues = [ + { + enabled: true, + contact: 'not-a-uri', + endpoint: 'not-a-websocket', + minRequestPowDifficulty: 300, + scopes: [], + }, + { + enabled: true, + contact: '', + endpoint: '', + scopes: [{ kinds: [65536, [100, 1]], since: 200, until: 100, windowSeconds: 0, extra: true }], + terms: [{ amount: -1, unit: '', period: 0, extra: true }], + }, + ].flatMap(peeringIssuesFor) +}) + +Then('the peering settings validation reports no issues', function(this: World>) { + expect(this.parameters.peeringValidationIssues).to.deep.equal([]) +}) + +Then('the peering settings validation reports an issue at {string}', function( + this: World>, + path: string, +) { + expect( + this.parameters.peeringValidationIssues.some((issue: { path: string }) => issue.path === path), + `expected a validation issue at "${path}"`, + ).to.equal(true) +}) + Then('the relay information response includes required NIP-11 CORS headers', function( this: World>, ) { diff --git a/test/integration/features/rate-limiter/rate-limiter.feature b/test/integration/features/rate-limiter/rate-limiter.feature index 52508e4b9..7e226aff6 100644 --- a/test/integration/features/rate-limiter/rate-limiter.feature +++ b/test/integration/features/rate-limiter/rate-limiter.feature @@ -4,4 +4,12 @@ Feature: Rate Limiter Given someone called Alice And Alice's message rate is already at the limit When Alice sends a text_note event expecting to be rate limited - Then Alice receives a notice with rate limited \ No newline at end of file + Then Alice receives a notice with rate limited + + @rate-limiter + Scenario: Alice's rate limit survives a sliding-window key left in Redis + Given someone called Alice + And Alice's Redis holds a sliding-window key left by an older version + And Alice's message rate is already at the limit + When Alice sends a text_note event expecting to be rate limited + Then Alice receives a notice with rate limited diff --git a/test/integration/features/rate-limiter/rate-limiter.ts b/test/integration/features/rate-limiter/rate-limiter.ts index 5737a440c..a4c8878cc 100644 --- a/test/integration/features/rate-limiter/rate-limiter.ts +++ b/test/integration/features/rate-limiter/rate-limiter.ts @@ -7,6 +7,7 @@ import { createEvent } from '../helpers' import { Event } from '../../../../src/@types/event' import { getCacheConfig } from '../../../../src/cache/client' import { SettingsStatic } from '../../../../src/utils/settings' +import { ewmaKey } from '../../../../src/utils/ewma-rate-limiter' let testCacheClient: any const rateLimitKeys: string[] = [] @@ -25,7 +26,10 @@ Given(/(\w+)'s message rate is already at the limit/, async function(name: strin const ws = this.parameters.clients[name] as WebSocket const address = (ws as any)._socket?.remoteAddress ?? '::1' const period = 60000 - const key = `${address}:message:${period}` + // EWMA reads its own namespace, and the message bucket carries the message + // type (a REQ is not refused because its EVENTs filled the bucket), so the + // seed has to name both: `:message:EVENT:` rather than `:message:`. + const key = ewmaKey(`${address}:message:EVENT:${period}`) await testCacheClient.hSet(key, { rate: '999', @@ -54,6 +58,24 @@ When(/(\w+) sends a text_note event expecting to be rate limited/, async functio this.parameters.events[name].push(event) }) +Given(/(\w+)'s Redis holds a sliding-window key left by an older version/, async function(name: string) { + const ws = this.parameters.clients[name] as WebSocket + const address = (ws as any)._socket?.remoteAddress ?? '::1' + const period = 60000 + // Exactly what a 2.x relay, or a sliding_window run, leaves behind: a sorted + // set under the un-namespaced, un-typed key name. EWMA reads + // `:message:EVENT:` behind its own prefix, so this data is + // inert rather than fatal — and reading it as a hash used to raise WRONGTYPE + // on every message, which is the regression this guards. + const key = `${address}:message:${period}` + + await testCacheClient.del(key) + await testCacheClient.zAdd(key, [{ score: Date.now(), value: `${Date.now()}:1` }]) + await testCacheClient.pExpire(key, period) + + rateLimitKeys.push(key) +}) + After({ tags: '@rate-limiter' }, async function() { SettingsStatic._settings = pipe( assocPath(['limits', 'message', 'rateLimits'], []), diff --git a/test/integration/features/redis-script-cache/redis-script-cache.feature b/test/integration/features/redis-script-cache/redis-script-cache.feature new file mode 100644 index 000000000..35adf664d --- /dev/null +++ b/test/integration/features/redis-script-cache/redis-script-cache.feature @@ -0,0 +1,18 @@ +Feature: The relay recovers when the cache forgets its Lua scripts + + A Redis restart empties the script cache, so every subsequent `EVALSHA` answers + `NOSCRIPT`. The relay calls `EVALSHA` for its rate limiters and its moderation + counters, which means the cache losing its scripts used to leave the relay + serving nothing at all: sockets stayed open, `HTTP` answered, and no event was + ever acknowledged again until the process was restarted (#1002). + + `SCRIPT FLUSH` reproduces that state exactly, without restarting a container and + without waiting for a reconnect. + + @script-cache + Scenario: A client publishes before and after the cache drops its scripts + Given someone called Alice + When Alice sends a text_note event with content "before the flush" + And the cache forgets its stored Lua scripts + And Alice sends a text_note event with content "after the flush" + And Alice sends a text_note event with content "and once more" diff --git a/test/integration/features/redis-script-cache/redis-script-cache.feature.ts b/test/integration/features/redis-script-cache/redis-script-cache.feature.ts new file mode 100644 index 000000000..ba6473e82 --- /dev/null +++ b/test/integration/features/redis-script-cache/redis-script-cache.feature.ts @@ -0,0 +1,47 @@ +import { After, Before, When } from '@cucumber/cucumber' +import { assocPath, pipe } from 'ramda' +import { createClient } from 'redis' + +import { Settings } from '../../../../src/@types/settings' +import { getCacheConfig } from '../../../../src/cache/client' +import { SettingsStatic } from '../../../../src/utils/settings' + +// One worker serves every scenario in this process, and controllers read +// `SettingsStatic._settings` through `createSettings()` on each request, so a +// mutation here would outlive the scenario and make whatever runs next depend on +// test order. The tagged After below puts the previous object back. +let settingsBefore: Settings | undefined + +// The relay only touches Lua when a rate limit is configured: both limiters +// return early on an empty list. A scenario that never configures one would pass +// whether or not the EVALSHA fallback exists, which is the definition of a test +// that proves nothing. A generous limit keeps the window from tripping while +// still forcing the script through `EVALSHA`. +Before({ tags: '@script-cache' }, async function () { + settingsBefore = SettingsStatic._settings + SettingsStatic._settings = pipe( + assocPath(['limits', 'rateLimiter', 'strategy'], 'ewma'), + assocPath(['limits', 'message', 'rateLimits'], [{ period: 60000, rate: 100 }]), + assocPath(['limits', 'message', 'ipWhitelist'], []), + )(SettingsStatic._settings) as any +}) + +After({ tags: '@script-cache' }, async function () { + SettingsStatic._settings = settingsBefore +}) + +// `SCRIPT FLUSH` is the whole of a Redis restart as far as the relay can tell: +// the script cache is memory-resident and empty, so the next EVALSHA answers +// NOSCRIPT. Doing it this way keeps the scenario deterministic — no container +// to restart, no reconnect to wait for, and it exercises the adapter's retry +// rather than the client's reconnect logic. +When(/^the cache forgets its stored Lua scripts$/, async function () { + const client = createClient(getCacheConfig()) + await client.connect() + + try { + await client.sendCommand(['SCRIPT', 'FLUSH']) + } finally { + await client.quit() + } +}) diff --git a/test/integration/settings.yaml b/test/integration/settings.yaml index 469f5d619..79dbe9a61 100644 --- a/test/integration/settings.yaml +++ b/test/integration/settings.yaml @@ -5,3 +5,17 @@ nip50: enabled: true language: simple maxQueryLength: 256 +peering: + enabled: true + contact: mailto:peering@example.com + endpoint: wss://relay.example.com + minRequestPowDifficulty: 16 + scopes: + - kinds: [1, 6] + windowSeconds: 3600 + - kinds: [3] + since: 1700000000 + terms: + - amount: 1000 + unit: msats + period: 2592000 diff --git a/test/unit/adapters/redis-adapter.spec.ts b/test/unit/adapters/redis-adapter.spec.ts index 45fb8462b..8054c7cef 100644 --- a/test/unit/adapters/redis-adapter.spec.ts +++ b/test/unit/adapters/redis-adapter.spec.ts @@ -29,6 +29,8 @@ describe('RedisAdapter', () => { get: sandbox.stub(), set: sandbox.stub(), zRemRangeByScore: sandbox.stub(), + zRemRangeByRank: sandbox.stub(), + zCard: sandbox.stub(), zRange: sandbox.stub(), expire: sandbox.stub(), zAdd: sandbox.stub(), @@ -163,6 +165,17 @@ describe('RedisAdapter', () => { }) }) + describe('removeRangeByRankFromSortedSet', () => { + it('calls client.zRemRangeByRank with correct arguments', async () => { + client.zRemRangeByRank.resolves(2) + + const result = await adapter.removeRangeByRankFromSortedSet('sorted-key', 0, 5) + + expect(client.zRemRangeByRank).to.have.been.calledOnceWithExactly('sorted-key', 0, 5) + expect(result).to.equal(2) + }) + }) + describe('getRangeFromSortedSet', () => { it('calls client.zRange with correct arguments', async () => { client.zRange.resolves(['a', 'b', 'c']) @@ -306,5 +319,18 @@ describe('RedisAdapter', () => { expect(client.scriptLoad).to.have.been.calledOnce expect(client.evalSha).to.have.been.calledTwice }) + + it('reloads script and retries after NOSCRIPT', async () => { + client.scriptLoad.onFirstCall().resolves('stale-sha') + client.scriptLoad.onSecondCall().resolves('fresh-sha') + client.evalSha.onFirstCall().rejects(Object.assign(new Error('NOSCRIPT No matching script'), { code: 'NOSCRIPT' })) + client.evalSha.onSecondCall().resolves('recovered') + + const result = await adapter.eval('local x = 1', ['key1'], ['arg1']) + + expect(client.scriptLoad).to.have.been.calledTwice + expect(client.evalSha).to.have.been.calledTwice + expect(result).to.equal('recovered') + }) }) }) diff --git a/test/unit/adapters/web-socket-adapter.spec.ts b/test/unit/adapters/web-socket-adapter.spec.ts index b5039996d..1c5f53d45 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -13,6 +13,7 @@ const { expect } = chai import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../../../src/constants/adapter' import { IWebSocketServerAdapter } from '../../../src/@types/adapters' +import { Negentropy, NegentropyStorageVector } from '../../../src/utils/negentropy' import { WebSocketAdapter } from '../../../src/adapters/web-socket-adapter' import { markActionableTarget, resetHiddenContentCache } from '../../../src/utils/hidden-content-cache' @@ -575,7 +576,7 @@ describe('WebSocketAdapter', () => { }) slidingWindowRateLimiter.returns({ - hit: sandbox.stub().resolves(true), + hit: sandbox.stub().resolves({ limited: true }), }) onMessage = client.on.getCalls().find((call: any) => call.args[0] === 'message').args[1] @@ -610,8 +611,8 @@ describe('WebSocketAdapter', () => { } const hitStub = sandbox.stub() - hitStub.onFirstCall().resolves(false) - hitStub.resolves(true) + hitStub.onFirstCall().resolves({ limited: false }) + hitStub.resolves({ limited: true }) slidingWindowRateLimiter.returns({ hit: hitStub }) createMessageHandler.returns(inFlightHandler) @@ -640,6 +641,46 @@ describe('WebSocketAdapter', () => { expect(sentFrame()).to.deep.equal(['CLOSED', 'q1', 'rate-limited: slow down', '60000']) }) + // The strategy's own estimate wins when it has one: a sliding window's + // period is the upper bound, an EWMA half-life is not. + it('sends the limiter\'s own backoff when it has one', async () => { + slidingWindowRateLimiter.returns({ + hit: sandbox.stub().resolves({ limited: true, retryAfterMs: 120000 }), + }) + + await onMessage(Buffer.from(JSON.stringify(['REQ', 'sub1', { kinds: [1] }]))) + + expect(sentFrame()).to.deep.equal(['CLOSED', 'sub1', 'rate-limited: slow down', '120000']) + }) + + it('buckets the limit by message type, so a REQ is not refused for EVENTs', async () => { + const hitStub = sandbox.stub() + hitStub.withArgs(Sinon.match(/:message:EVENT:/)).resolves({ limited: true }) + hitStub.withArgs(Sinon.match(/:message:REQ:/)).resolves({ limited: false }) + slidingWindowRateLimiter.returns({ hit: hitStub }) + + await onMessage(Buffer.from(JSON.stringify(['REQ', 'sub1', { kinds: [1] }]))) + + expect(hitStub).to.have.been.calledOnceWithExactly( + '127.0.0.1:message:REQ:60000', + 1, + { period: 60000, rate: 1 }, + ) + }) + + // A closed set of types, or a client could rotate invented ones and keep + // every bucket empty. + it('buckets an invented message type with everything else unrecognised', async () => { + const hitStub = sandbox.stub().resolves({ limited: false }) + slidingWindowRateLimiter.returns({ hit: hitStub }) + + await onMessage(Buffer.from(JSON.stringify(['AAAA']))) + await onMessage(Buffer.from(JSON.stringify(['BBBB']))) + + expect(hitStub.firstCall.args[0]).to.equal('127.0.0.1:message:OTHER:60000') + expect(hitStub.secondCall.args[0]).to.equal('127.0.0.1:message:OTHER:60000') + }) + it('falls back to the notice when the frame is not valid JSON', async () => { await onMessage(Buffer.from('not-json')) @@ -651,7 +692,7 @@ describe('WebSocketAdapter', () => { it('stops hitting subsequent rate limit windows once one is exceeded', async () => { client.readyState = WebSocket.OPEN - const hitStub = sandbox.stub().resolves(true) + const hitStub = sandbox.stub().resolves({ limited: true }) settingsFactory.returns({ network: { remoteIpHeader: '' }, @@ -950,6 +991,55 @@ describe('WebSocketAdapter', () => { expect(adapter.addAuthenticatedPubkey(pubkey, eventId)).to.equal(false) }) + it('exposes a peer pubkey only after it authenticates', () => { + const pubkey = 'a'.repeat(64) + settingsFactory.returns({ + peering: { enabled: true, peers: [{ pubkey, relayUrl: 'wss://peer.example.com' }] }, + }) + + adapter.setPeerPubkey(pubkey) + expect(adapter.getPeerPubkey()).to.equal(undefined) + + adapter.addAuthenticatedPubkey(pubkey, '1'.repeat(64)) + adapter.setPeerPubkey(pubkey) + expect(adapter.getPeerPubkey()).to.equal(pubkey) + }) + + it('drops peer status when the AUTH session expires', () => { + const clock = sandbox.useFakeTimers({ now: 1_700_000_000_000 }) + const pubkey = 'a'.repeat(64) + settingsFactory.returns({ + nip42: { sessionExpirySeconds: 60 }, + peering: { enabled: true, peers: [{ pubkey, relayUrl: 'wss://peer.example.com' }] }, + }) + + adapter.addAuthenticatedPubkey(pubkey, '1'.repeat(64)) + adapter.setPeerPubkey(pubkey) + expect(adapter.getPeerPubkey()).to.equal(pubkey) + + clock.tick(60_000) + expect(adapter.getPeerPubkey()).to.equal(undefined) + }) + + it('revokes peer status when the peer is disabled or removed from settings', () => { + const pubkey = 'a'.repeat(64) + const peer = { pubkey, relayUrl: 'wss://peer.example.com' } + settingsFactory.returns({ peering: { enabled: true, peers: [peer] } }) + + adapter.addAuthenticatedPubkey(pubkey, '1'.repeat(64)) + adapter.setPeerPubkey(pubkey) + expect(adapter.getPeerPubkey()).to.equal(pubkey) + + settingsFactory.returns({ peering: { enabled: true, peers: [{ ...peer, enabled: false }] } }) + expect(adapter.getPeerPubkey()).to.equal(undefined) + + adapter.setPeerPubkey(pubkey) + expect(adapter.getPeerPubkey()).to.equal(undefined) + + settingsFactory.returns({ peering: { enabled: true, peers: [] } }) + expect(adapter.getPeerPubkey()).to.equal(undefined) + }) + it('expires authenticated pubkeys after sessionExpirySeconds', () => { const clock = sandbox.useFakeTimers({ now: 1_700_000_000_000 }) settingsFactory.returns({ nip42: { sessionExpirySeconds: 60 } }) @@ -976,4 +1066,91 @@ describe('WebSocketAdapter', () => { adapter2.removeAllListeners() }) }) + + describe('NIP-77 negentropy sessions', () => { + const emptyStorage = () => { + const storage = new NegentropyStorageVector() + storage.seal() + + return new Negentropy(storage) + } + + const openSession = (id: string, idleTimeoutMs = 60000) => { + const sessions = adapter.getNegentropySessions() + sessions.activate( + id, + sessions.reserve(id, 3) as symbol, + { negentropy: emptyStorage(), filter: {} }, + idleTimeoutMs, + ) + } + + const sentMessages = () => client.send.getCalls().map((call: any) => JSON.parse(call.args[0])) + + const triggerMessage = async (payload: unknown) => { + const call = client.on.getCalls().find((c: any) => c.args[0] === 'message') + await call.args[1](Buffer.from(JSON.stringify(payload))) + } + + it('hands the same sessions to every caller', () => { + expect(adapter.getNegentropySessions()).to.equal(adapter.getNegentropySessions()) + }) + + it('dispatches NEG-OPEN, NEG-MSG and NEG-CLOSE to a message handler', async () => { + const handleMessage = sandbox.stub().resolves() + createMessageHandler.returns({ handleMessage }) + + await triggerMessage(['NEG-OPEN', 'sync1', { kinds: [1] }, '6100000200']) + await triggerMessage(['NEG-MSG', 'sync1', '6100']) + await triggerMessage(['NEG-CLOSE', 'sync1']) + + expect(handleMessage.getCalls().map((call: any) => call.args[0][0])).to.deep.equal([ + 'NEG-OPEN', + 'NEG-MSG', + 'NEG-CLOSE', + ]) + }) + + it('replies with a NOTICE to a NEG-OPEN whose initial message is not hex', async () => { + await triggerMessage(['NEG-OPEN', 'sync1', {}, 'not hex']) + + expect(createMessageHandler).to.not.have.been.called + expect(sentMessages()).to.have.lengthOf(1) + expect(sentMessages()[0][0]).to.equal('NOTICE') + expect(sentMessages()[0][1]).to.match(/^invalid:/) + }) + + it('tells the client when a session times out', () => { + const clock = sandbox.useFakeTimers() + openSession('sync1', 1000) + + clock.tick(1000) + + expect(sentMessages()).to.deep.include(['NEG-ERR', 'sync1', 'closed: negentropy session timed out']) + expect(adapter.getNegentropySessions().getSessionIds()).to.deep.equal([]) + }) + + it('drops every session and its timer when the client disconnects', () => { + const clock = sandbox.useFakeTimers() + openSession('sync1', 1000) + const onClose = client.on.getCalls().find((call: any) => call.args[0] === 'close').args[1] + + onClose() + clock.tick(5000) + + expect(adapter.getNegentropySessions().getSessionIds()).to.deep.equal([]) + expect(sentMessages().filter((message: any[]) => message[0] === 'NEG-ERR')).to.deep.equal([]) + }) + + it('tells the client its sessions are closed when the relay drains', () => { + openSession('a') + openSession('b') + + adapter.drainAndClose('relay shutting down') + + expect(sentMessages()).to.deep.include(['NEG-ERR', 'a', 'closed: relay shutting down']) + expect(sentMessages()).to.deep.include(['NEG-ERR', 'b', 'closed: relay shutting down']) + expect(adapter.getNegentropySessions().getSessionIds()).to.deep.equal([]) + }) + }) }) diff --git a/test/unit/app/app.spec.ts b/test/unit/app/app.spec.ts index 356312ded..7e8abb6dc 100644 --- a/test/unit/app/app.spec.ts +++ b/test/unit/app/app.spec.ts @@ -17,7 +17,12 @@ const { expect } = chai describe('App', () => { let sandbox: Sinon.SinonSandbox - let fakeProcess: EventEmitter & { exit: Sinon.SinonStub; on: Sinon.SinonStub; env: NodeJS.ProcessEnv } + let fakeProcess: EventEmitter & { + exit: Sinon.SinonStub + on: Sinon.SinonStub + env: NodeJS.ProcessEnv + stdout: { write: Sinon.SinonStub } + } let cluster: EventEmitter & { on: Sinon.SinonStub workers: Record @@ -49,7 +54,14 @@ describe('App', () => { exit: sandbox.stub(), on: sandbox.stub().returnsThis(), env: {}, - }) as EventEmitter & { exit: Sinon.SinonStub; on: Sinon.SinonStub; env: NodeJS.ProcessEnv } + // The banner is written straight to stdout, so the process fake needs one. + stdout: { write: sandbox.stub() }, + }) as EventEmitter & { + exit: Sinon.SinonStub + on: Sinon.SinonStub + env: NodeJS.ProcessEnv + stdout: { write: Sinon.SinonStub } + } cluster = Object.assign(new EventEmitter(), { on: sandbox.stub().returnsThis(), @@ -136,6 +148,76 @@ describe('App', () => { expect(cluster.fork).not.to.have.been.called }) + // The banner is for a person reading `docker compose logs`: plain text on + // stdout, never a log record with the newlines escaped. + it('writes the banner to stdout as text, not through the logger', () => { + sandbox.stub(SettingsStatic, 'watchSettings').returns([]) + sandbox.stub(operatorNotificationEnqueue, 'enqueueOperatorNotification').resolves() + sandbox.stub(torClient, 'addOnion').rejects(new Error('tor is not configured')) + let pid = 1001 + cluster.fork.callsFake(() => createWorker(String(pid), pid++)) + const app = new App( + fakeProcess as any, + cluster as any, + sandbox.stub().returns({ + payments: { enabled: false }, + workers: { count: 1 }, + eventStore: { backend: 'postgres' }, + } as Settings), + ) + + app.run() + + const written = fakeProcess.stdout.write.getCalls().map((call) => call.args[0]).join('') + + expect(written).to.include('███▄ █ ▒█████') + expect(written).to.match(/v\d+\.\d+\.\d+/) + expect(written).not.to.include('{"level"') + }) + + // LOG_FORMAT=json promises a stream in which every line parses. The logo + // cannot keep that promise, so it becomes one record with the same facts. + it('writes one structured record instead of the banner when LOG_FORMAT=json', () => { + sandbox.stub(SettingsStatic, 'watchSettings').returns([]) + sandbox.stub(operatorNotificationEnqueue, 'enqueueOperatorNotification').resolves() + sandbox.stub(torClient, 'addOnion').rejects(new Error('tor is not configured')) + let pid = 2001 + cluster.fork.callsFake(() => createWorker(String(pid), pid++)) + fakeProcess.env = { LOG_FORMAT: 'json' } + const app = new App( + fakeProcess as any, + cluster as any, + sandbox.stub().returns({ + payments: { enabled: false }, + workers: { count: 1 }, + eventStore: { backend: 'postgres' }, + } as Settings), + ) + + app.run() + + const lines = fakeProcess.stdout.write.getCalls() + .map((call) => String(call.args[0]).trim()) + .filter(Boolean) + + expect(lines).to.have.lengthOf(1) + + const record = JSON.parse(lines[0]) + + expect(record).to.include({ + level: 30, + msg: 'relay starting', + payments: 'disabled', + eventStore: 'postgres', + clientWorkers: 1, + maintenanceWorkers: 1, + relayMonitor: false, + dvmWorkers: 0, + }) + expect(record.version).to.match(/^\d+\.\d+\.\d+/) + expect(record.time).to.be.a('number') + }) + it('hands every worker, re-forked ones included, the event store resolved at startup', async () => { // run() reads both from process.env const env = { ...process.env } @@ -178,4 +260,30 @@ describe('App', () => { expect(cluster.fork).to.have.been.calledThrice expect(cluster.fork.thirdCall.args[0]).to.deep.equal(cluster.fork.firstCall.args[0]) }) + it('does not start a mirroring worker for mirroring.static, which is no longer supported', () => { + const env = { ...process.env } + delete env.WORKER_COUNT + delete env.RELAY_BROADCAST_FANOUT + sandbox.stub(process, 'env').value(env) + sandbox.useFakeTimers() + sandbox.stub(SettingsStatic, 'watchSettings').returns([]) + sandbox.stub(operatorNotificationEnqueue, 'enqueueOperatorNotification').resolves() + sandbox.stub(torClient, 'addOnion').rejects(new Error('tor is not configured')) + let pid = 1001 + cluster.fork.callsFake(() => createWorker(String(pid), pid++)) + const app = new App( + fakeProcess as any, + cluster as any, + sandbox.stub().returns({ + payments: { enabled: false }, + workers: { count: 1 }, + eventStore: { backend: 'postgres' }, + mirroring: { static: [{ address: 'wss://mirror.example' }, { address: 'wss://other.example' }] }, + } as unknown as Settings), + ) + + app.run() + + expect(cluster.fork.getCalls().map((call) => call.args[0].WORKER_TYPE)).to.deep.equal(['worker', 'maintenance']) + }) }) diff --git a/test/unit/app/maintenance-worker.spec.ts b/test/unit/app/maintenance-worker.spec.ts index da3ff4814..dc3defe1c 100644 --- a/test/unit/app/maintenance-worker.spec.ts +++ b/test/unit/app/maintenance-worker.spec.ts @@ -359,6 +359,48 @@ describe('MaintenanceWorker', () => { expect(paymentsService.getPendingInvoices).not.to.have.been.called }) + describe('subscription lifecycle', () => { + let subscriptionService: { processLifecycle: Sinon.SinonStub } + + beforeEach(() => { + subscriptionService = { processLifecycle: sandbox.stub().resolves(0) } + worker = new MaintenanceWorker( + fakeProcess as any, + paymentsService, + maintenanceService, + settings as any, + nip05VerificationRepository, + notificationOutboxService, + notificationDeliveryLogRepository, + notificationOutboxRepository, + undefined, + subscriptionService as any, + ) + }) + + it('runs the subscription lifecycle once per pass', async () => { + await (worker as any).onSchedule() + + expect(subscriptionService.processLifecycle).to.have.been.calledOnce + }) + + it('skips it while payments are disabled', async () => { + settingsState.payments = { enabled: false } as any + + await (worker as any).onSchedule() + + expect(subscriptionService.processLifecycle).not.to.have.been.called + }) + + it('still clears old events and finishes the pass when the lifecycle fails', async () => { + subscriptionService.processLifecycle.rejects(new Error('db down')) + + await (worker as any).onSchedule() + + expect(maintenanceService.clearOldEvents).to.have.been.calledOnce + }) + }) + it('skips an invoice when the processor returns no id', async () => { settingsState.payments = { enabled: true } as any paymentsService.getPendingInvoices.resolves([pendingInvoice]) diff --git a/test/unit/app/relay-monitor-worker.spec.ts b/test/unit/app/relay-monitor-worker.spec.ts index 7d4df9b2d..ada537774 100644 --- a/test/unit/app/relay-monitor-worker.spec.ts +++ b/test/unit/app/relay-monitor-worker.spec.ts @@ -9,7 +9,9 @@ import { getProbeIntervalMs, RelayMonitorWorker, } from '../../../src/app/relay-monitor-worker' +import { ICacheAdapter } from '../../../src/@types/adapters' import { Settings } from '../../../src/@types/settings' +import { ADMIN_OPS_HEARTBEAT_KEEPALIVE_MS, resetAdminOpsStore } from '../../../src/utils/admin-ops-store' import * as metricsTelemetry from '../../../src/telemetry/metrics' import { ProbeResult } from '../../../src/utils/relay-probe/types' @@ -17,6 +19,8 @@ chai.use(sinonChai) const { expect } = chai +const flushAsyncWork = () => new Promise((resolve) => setImmediate(resolve)) + describe('RelayMonitorWorker', () => { let sandbox: Sinon.SinonSandbox let worker: RelayMonitorWorker @@ -28,6 +32,10 @@ describe('RelayMonitorWorker', () => { getLatest: Sinon.SinonStub } let probeRunner: Sinon.SinonStub + let historyStore: { + append: Sinon.SinonStub + list: Sinon.SinonStub + } const probeResult = (): ProbeResult => ({ @@ -47,6 +55,7 @@ describe('RelayMonitorWorker', () => { }) as ProbeResult beforeEach(() => { + resetAdminOpsStore() sandbox = Sinon.createSandbox() sandbox.stub(metricsTelemetry, 'shutdownMetricsTelemetry').resolves() @@ -77,9 +86,20 @@ describe('RelayMonitorWorker', () => { saveLatest: sandbox.stub().resolves(), getLatest: sandbox.stub().resolves(null), } + historyStore = { + append: sandbox.stub().resolves(), + list: sandbox.stub().resolves([]), + } probeRunner = sandbox.stub().resolves(probeResult()) - worker = new RelayMonitorWorker(fakeProcess as unknown as NodeJS.Process, settings, snapshotStore, probeRunner) + worker = new RelayMonitorWorker( + fakeProcess as unknown as NodeJS.Process, + settings, + snapshotStore, + probeRunner, + undefined, + historyStore as any, + ) }) afterEach(() => { @@ -101,18 +121,23 @@ describe('RelayMonitorWorker', () => { const clock = sandbox.useFakeTimers() worker.run() - await Promise.resolve() + await clock.tickAsync(0) + await clock.tickAsync(0) expect(probeRunner).to.have.been.calledOnceWith('wss://relay.example.com', buildProbeOptions(settingsState)) expect(snapshotStore.saveLatest).to.have.been.calledOnce + expect(historyStore.append).to.have.been.calledOnce probeRunner.resetHistory() snapshotStore.saveLatest.resetHistory() + historyStore.append.resetHistory() await clock.tickAsync(60_000) + await clock.tickAsync(0) expect(probeRunner).to.have.been.calledOnce expect(snapshotStore.saveLatest).to.have.been.calledOnce + expect(historyStore.append).to.have.been.calledOnce clock.restore() }) @@ -174,10 +199,151 @@ describe('RelayMonitorWorker', () => { ) worker.run() - await Promise.resolve() - await Promise.resolve() + await flushAsyncWork() + await flushAsyncWork() expect(eventPublisher.publishAfterProbe).to.have.been.calledOnce expect(eventPublisher.publishAfterProbe.firstCall.args[0].status).to.equal('ok') }) + + it('keepalive republishes the last probe heartbeat fields', async () => { + const workerPayloads: string[] = [] + const opsCache: ICacheAdapter = { + getKey: async () => '', + hasKey: async () => false, + setKey: async (key, value) => { + if (key.startsWith('admin:ops:worker:')) { + workerPayloads.push(value) + } + return true + }, + setKeyIfNotExists: async () => true, + addToSortedSet: async () => 0, + removeRangeByScoreFromSortedSet: async () => 0, + removeRangeByRankFromSortedSet: async () => 0, + getRangeFromSortedSet: async () => [], + setKeyExpiry: async () => {}, + deleteKey: async () => 0, + getHKey: async () => '', + setHKey: async () => true, + addToSet: async () => 1, + getSetMembers: async () => [], + eval: async () => null, + } + + settingsState.nip66!.targets = ['wss://relay.example.com'] + probeRunner.rejects(new Error('probe failed')) + + const clock = sandbox.useFakeTimers() + worker = new RelayMonitorWorker( + fakeProcess as unknown as NodeJS.Process, + settings, + snapshotStore, + probeRunner, + undefined, + historyStore as any, + opsCache, + ) + + worker.run() + await clock.tickAsync(0) + await clock.tickAsync(0) + + expect(workerPayloads.length).to.be.greaterThan(0) + const afterFailure = JSON.parse(workerPayloads[workerPayloads.length - 1]!) + expect(afterFailure.lastError).to.equal('Probe run produced no results') + + const countBeforeKeepalive = workerPayloads.length + await clock.tickAsync(ADMIN_OPS_HEARTBEAT_KEEPALIVE_MS) + await clock.tickAsync(0) + + expect(workerPayloads.length).to.be.greaterThan(countBeforeKeepalive) + const afterKeepalive = JSON.parse(workerPayloads[workerPayloads.length - 1]!) + expect(afterKeepalive.lastError).to.equal('Probe run produced no results') + expect(afterKeepalive.lastRunStartedAt).to.equal(afterFailure.lastRunStartedAt) + + clock.restore() + }) + + it('clears lastError after a later successful probe run', async () => { + const workerPayloads: string[] = [] + const opsCache: ICacheAdapter = { + getKey: async () => '', + hasKey: async () => false, + setKey: async (key, value) => { + if (key.startsWith('admin:ops:worker:')) { + workerPayloads.push(value) + } + return true + }, + setKeyIfNotExists: async () => true, + addToSortedSet: async () => 0, + removeRangeByScoreFromSortedSet: async () => 0, + removeRangeByRankFromSortedSet: async () => 0, + getRangeFromSortedSet: async () => [], + setKeyExpiry: async () => {}, + deleteKey: async () => 0, + getHKey: async () => '', + setHKey: async () => true, + addToSet: async () => 1, + getSetMembers: async () => [], + eval: async () => null, + } + + settingsState.nip66!.targets = ['wss://relay.example.com'] + probeRunner.onFirstCall().rejects(new Error('probe failed')) + probeRunner.onSecondCall().resolves(probeResult()) + + const clock = sandbox.useFakeTimers() + worker = new RelayMonitorWorker( + fakeProcess as unknown as NodeJS.Process, + settings, + snapshotStore, + probeRunner, + undefined, + historyStore as any, + opsCache, + ) + + worker.run() + await clock.tickAsync(0) + await clock.tickAsync(0) + + expect(workerPayloads.length).to.be.greaterThan(0) + const afterFailure = JSON.parse(workerPayloads[workerPayloads.length - 1]!) + expect(afterFailure.lastError).to.equal('Probe run produced no results') + + await clock.tickAsync(60_000) + await clock.tickAsync(0) + + expect(workerPayloads.length).to.be.greaterThan(1) + const afterSuccess = JSON.parse(workerPayloads[workerPayloads.length - 1]!) + expect(afterSuccess.lastError).to.equal(undefined) + expect(afterSuccess.lastRunFinishedAt).to.be.a('string') + + clock.restore() + }) + + it('still publishes NIP-66 events when probe history append fails', async () => { + const eventPublisher = { + publishAfterProbe: sandbox.stub().resolves(), + } + + historyStore.append.rejects(new Error('redis unavailable')) + + worker = new RelayMonitorWorker( + fakeProcess as unknown as NodeJS.Process, + settings, + snapshotStore, + probeRunner, + eventPublisher, + historyStore as any, + ) + + worker.run() + await flushAsyncWork() + await flushAsyncWork() + + expect(eventPublisher.publishAfterProbe).to.have.been.calledOnce + }) }) diff --git a/test/unit/ci/compose-network.spec.ts b/test/unit/ci/compose-network.spec.ts new file mode 100644 index 000000000..e013c44f8 --- /dev/null +++ b/test/unit/ci/compose-network.spec.ts @@ -0,0 +1,109 @@ +import chai from 'chai' +import { readFileSync, readdirSync } from 'fs' +import yaml from 'js-yaml' +import { join } from 'path' + +const { expect } = chai + +/** + * Docker allocates dynamic addresses from the bottom of a subnet, while several + * services are pinned by `ipv4_address`. Without an `ip_range` the two pools + * overlap, and which container wins is decided by creation order — so + * `otel-collector` could take `10.10.10.2` from `nostream` and the relay then + * fails with "Address already in use" (#929). These checks hold the pools apart. + */ + +type Service = { + networks?: Record | string[] +} + +type Network = { + ipam?: { config?: Array<{ subnet?: string; gateway?: string; ip_range?: string }> } +} + +type Compose = { services?: Record; networks?: Record } + +const composeFiles = (): string[] => + readdirSync(process.cwd()) + .filter((file) => /^docker-compose(\.[\w-]+)?\.ya?ml$/.test(file)) + .sort() + +const load = (file: string): Compose => + yaml.load(readFileSync(join(process.cwd(), file), 'utf-8')) as Compose + +/** Every static address a compose file pins, as `file: service -> address`. */ +const staticAddresses = (): Array<{ file: string; service: string; address: string }> => + composeFiles().flatMap((file) => { + const services = load(file).services ?? {} + + return Object.entries(services).flatMap(([name, service]) => { + const networks = service.networks + if (!networks || Array.isArray(networks) || typeof networks === 'string') return [] + + return Object.values(networks) + .filter((entry): entry is { ipv4_address?: string } => typeof entry === 'object' && entry !== null) + .filter((entry) => typeof entry.ipv4_address === 'string') + .map((entry) => ({ file, service: name, address: entry.ipv4_address as string })) + }) + }) + +const ipv4ToInt = (address: string): number => + address.split('.').reduce((total, octet) => total * 256 + Number(octet), 0) + +const parseCidr = (cidr: string): { base: number; size: number } => { + const [address, bits] = cidr.split('/') + + return { base: ipv4ToInt(address), size: 2 ** (32 - Number(bits)) } +} + +const contains = (cidr: string, address: string): boolean => { + const { base, size } = parseCidr(cidr) + const value = ipv4ToInt(address) + + return value >= base && value < base + size +} + +describe('docker-compose network pools', () => { + const defaultNetwork = load('docker-compose.yml').networks?.default + const ipam = defaultNetwork?.ipam?.config?.[0] ?? {} + + it('declares an ip_range beside the static addresses', () => { + expect(ipam.subnet, 'docker-compose.yml must declare a subnet').to.be.a('string') + expect( + ipam.ip_range, + 'docker-compose.yml must confine dynamic allocation with ip_range, or it can hand a pinned address to another container' + ).to.be.a('string') + }) + + it('keeps the dynamic range inside the subnet', () => { + const { base, size } = parseCidr(ipam.ip_range as string) + const subnet = parseCidr(ipam.subnet as string) + + expect(base).to.be.at.least(subnet.base) + expect(base + size).to.be.at.most(subnet.base + subnet.size) + }) + + it('keeps the gateway outside the dynamic range', () => { + expect(contains(ipam.subnet as string, ipam.gateway as string), 'gateway must be in the subnet').to.equal(true) + expect(contains(ipam.ip_range as string, ipam.gateway as string), 'gateway must not be allocatable').to.equal( + false + ) + }) + + it('keeps every static address outside the dynamic range', () => { + const addresses = staticAddresses() + + expect(addresses.length, 'no static addresses found — the extraction is broken').to.be.greaterThan(0) + + for (const { file, service, address } of addresses) { + expect( + contains(ipam.subnet as string, address), + `${file}: ${service} pins ${address}, which is outside the declared subnet` + ).to.equal(true) + expect( + contains(ipam.ip_range as string, address), + `${file}: ${service} pins ${address}, which is inside the dynamic pool ${String(ipam.ip_range)}` + ).to.equal(false) + } + }) +}) diff --git a/test/unit/ci/node-base-pin.spec.ts b/test/unit/ci/node-base-pin.spec.ts new file mode 100644 index 000000000..c39e01ed0 --- /dev/null +++ b/test/unit/ci/node-base-pin.spec.ts @@ -0,0 +1,82 @@ +import chai from 'chai' +import { readFileSync, readdirSync } from 'fs' +import yaml from 'js-yaml' +import { join } from 'path' + +const { expect } = chai + +/** + * One node base image, pinned by digest, everywhere this repo names one. + * + * #1005 pinned the build stages of `Dockerfile` while `Dockerfile.railwayapp`, + * `Dockerfile.test` and both compose files still said `node:24-alpine`, so the + * relay image, the CI image, the Railway image and the two migration containers + * could each start from a different base. A floating tag also moves under a + * release: the same commit built twice can run different node code. A tag alone + * is not enough for the same reason, which is why the digest is required too. + * + * When the base moves, it moves in every file in the same commit — this test is + * the thing that makes that true instead of hoped for. + */ + +type Compose = { services?: Record } + +const NODE_REF = /^node:/ +const DIGEST_PINNED = /@sha256:[0-9a-f]{64}$/ + +const dockerfiles = (): string[] => + readdirSync(process.cwd()) + .filter((file) => /^Dockerfile(\.[\w-]+)?$/.test(file)) + .sort() + +const composeFiles = (): string[] => { + const roots = readdirSync(process.cwd()).filter((file) => /^docker-compose(\.[\w-]+)?\.ya?ml$/.test(file)) + const integration = readdirSync(join(process.cwd(), 'test/integration')) + .filter((file) => /^docker-compose(\.[\w-]+)?\.ya?ml$/.test(file)) + .map((file) => join('test/integration', file)) + + return [...roots, ...integration].sort() +} + +/** Every `FROM node:…` and `image: node:…` in the repo, with where it came from. */ +const references = (): Array<{ where: string; ref: string }> => [ + ...dockerfiles().flatMap((file) => + readFileSync(join(process.cwd(), file), 'utf-8') + .split('\n') + .map((line, index) => ({ line, where: `${file}:${index + 1}` })) + .filter(({ line }) => /^FROM\s/.test(line)) + .map(({ line, where }) => ({ where, ref: line.split(/\s+/)[1] })) + .filter(({ ref }) => NODE_REF.test(ref)) + ), + ...composeFiles().flatMap((file) => { + const services = (yaml.load(readFileSync(join(process.cwd(), file), 'utf-8')) as Compose).services ?? {} + + return Object.entries(services) + .filter(([, service]) => NODE_REF.test(service.image ?? '')) + .map(([name, service]) => ({ where: `${file}: ${name}`, ref: service.image as string })) + }) +] + +describe('node base image pin', () => { + const refs = references() + + it('finds the base image wherever it is named', () => { + // A glob that silently matches nothing would make every check below pass. + expect(refs.map(({ where }) => where)).to.include.members([ + 'Dockerfile:3', + 'Dockerfile.railwayapp:4', + 'docker-compose.yml: nostream-migrate', + 'test/integration/docker-compose.yml: migrations-test' + ]) + }) + + it('pins each one by digest', () => { + expect(refs.filter(({ ref }) => !DIGEST_PINNED.test(ref)).map(({ where, ref }) => `${where} -> ${ref}`)).to.deep.equal( + [] + ) + }) + + it('uses the same reference everywhere', () => { + expect([...new Set(refs.map(({ ref }) => ref))]).to.have.length(1) + }) +}) diff --git a/test/unit/ci/publish-container-image.spec.ts b/test/unit/ci/publish-container-image.spec.ts index bd3529a98..71289c6ba 100644 --- a/test/unit/ci/publish-container-image.spec.ts +++ b/test/unit/ci/publish-container-image.spec.ts @@ -1,36 +1,201 @@ -import { existsSync, readFileSync } from 'fs' -import { join } from 'path' - import chai from 'chai' +import { execFileSync } from 'child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import { load } from 'js-yaml' +import { tmpdir } from 'os' +import { join } from 'path' const { expect } = chai type Workflow = { - jobs: Record + jobs: Record< + string, + { + if?: string + needs?: string[] + permissions?: Record + steps?: Array<{ id?: string; run?: string; with?: Record }> + } + > +} + +// Changesets tags each workspace package on publish, so a relay release is +// `nostream@X.Y.Z` while a website release is `nostream-website@X.Y.Z`. The +// `if:` expression below is the only thing standing between a website release +// and a relay image built from the wrong commit, so it is exercised rather than +// string-matched: a typo that still contains the right substring would pass a +// `to.include` and skip every relay release. +const evaluateReleaseGate = (tagName: string, isDispatch = false): boolean => { + const expression = + (load( + readFileSync(join(process.cwd(), '.github', 'workflows', 'release.yml'), 'utf-8'), + ) as Workflow).jobs['build-and-push-image'].if ?? '' + // Only two clause forms are allowed in this expression; anything else is a + // workflow change that needs review rather than a silent pass. + const clauses = String(expression) + .split('||') + .map((clause) => clause.trim()) + const startsWith = (value: string, prefix: string) => value.startsWith(prefix) + const toJs = (clause: string): string => { + const prefix = clause.match(/^startsWith\(github\.event\.release\.tag_name, '([^']*)'\)$/) + if (prefix) return `startsWith(tagName, ${JSON.stringify(prefix[1])})` + const event = clause.match(/^github\.event_name == '([^']*)'$/) + if (event) return `isDispatch === ${event[1] === 'workflow_dispatch'}` + throw new Error(`unsupported clause in the release gate: ${clause}`) + } + // eslint-disable-next-line no-new-func + return new Function('tagName', 'startsWith', 'isDispatch', `return ${clauses.map(toJs).join(' || ')}`)( + tagName, + startsWith, + isDispatch, + ) as boolean +} + +// The tags are computed by a `run:` block, not by metadata-action's rules, +// because a changesets release tag is `nostream@X.Y.Z` and `@` is not legal in a +// Docker tag (#964). The block is executed here rather than string-matched: the +// defect it replaced was a tag nobody validated, only compared. +const tagStep = (workflow: Workflow): string => { + const step = (workflow.jobs['build-and-push-image'].steps ?? []).find((entry) => entry.id === 'tags') + expect(step, 'release.yml must compute its image tags').to.exist + const run = step?.run ?? '' + expect(run, 'the tag step must compute the tags itself, not inherit them').to.not.equal('') + return run +} + +// Executes the step's own `run:` block with the inputs it maps from the release +// event, and returns the tags it wrote to the job output. `undefined` means a +// `workflow_dispatch`, where the event carries no flags at all. +const imageTags = (workflow: Workflow, releaseTag: string, prerelease?: boolean): string[] => { + const dir = mkdtempSync(join(tmpdir(), 'release-tags-')) + try { + const out = join(dir, 'github-output') + writeFileSync(out, '') + execFileSync('bash', ['-c', tagStep(workflow)], { + stdio: 'pipe', + env: { + ...process.env, + REGISTRY: 'ghcr.io', + IMAGE_NAME: 'cameri/nostream', + RELEASE_TAG: releaseTag, + ...(prerelease === undefined ? {} : { PRERELEASE: String(prerelease) }), + GITHUB_OUTPUT: out, + }, + }) + const line = readFileSync(out, 'utf-8') + .split('\n') + .find((entry) => entry.startsWith('tags=')) + expect(line, 'the step must write its tags to the job output').to.exist + return (line ?? '') + .slice('tags='.length) + .split(',') + .map((tag) => tag.replace('ghcr.io/cameri/nostream:', '')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } } describe('container image publishing workflow', () => { - const workflow = load(readFileSync(join(process.cwd(), '.github', 'workflows', 'checks.yml'), 'utf-8')) as Workflow - - it('publishes only on main after all CI checks complete', () => { - const publish = workflow.jobs['publish-container-image'] - - expect(publish.needs).to.deep.equal([ - 'changes', - 'lint', - 'build-check', - 'test-units-and-cover', - 'test-integrations-and-cover', - 'post-tests', - ]) - expect(publish.if).to.include("always() && github.event_name == 'push' && github.ref == 'refs/heads/main'") - expect(publish.if).to.include("needs.changes.result == 'success'") - expect(publish.if).to.include("needs.changes.outputs.src != 'true'") - for (const job of publish.needs.filter((job) => !['changes', 'post-tests'].includes(job))) { - expect(publish.if).to.include(`needs.${job}.result == 'success'`) + const checksWorkflow = load( + readFileSync(join(process.cwd(), '.github', 'workflows', 'checks.yml'), 'utf-8'), + ) as Workflow + const releaseWorkflow = load( + readFileSync(join(process.cwd(), '.github', 'workflows', 'release.yml'), 'utf-8'), + ) as Workflow + + it('does not publish container images in checks.yml', () => { + expect(checksWorkflow.jobs['publish-container-image']).to.be.undefined + for (const [jobName, job] of Object.entries(checksWorkflow.jobs)) { + expect( + job.permissions?.packages, + `job ${jobName} in checks.yml should not have packages write permission`, + ).to.not.equal('write') } - expect(publish.if).to.include("needs.post-tests.result == 'success'") + }) + + it('publishes container image only in release.yml on published releases', () => { + const buildAndPush = releaseWorkflow.jobs['build-and-push-image'] + expect(buildAndPush).to.exist + expect(buildAndPush.permissions?.packages).to.equal('write') expect(existsSync(join(process.cwd(), '.github', 'workflows', 'publish-container-image.yml'))).to.equal(false) }) + + it('builds the image for the tag format changesets actually creates', () => { + // The last six relay releases (v3.0.0 … v3.4.1) were tagged vX.Y.Z and + // published, but every release since the repo moved to changesets tagging + // (nostream@3.5.0 onward) was skipped - no image was published for it. + expect(evaluateReleaseGate('nostream@3.6.2'), 'a relay release must build').to.equal(true) + expect(evaluateReleaseGate('v3.4.1'), 'a pre-changesets relay release must still build').to.equal(true) + }) + + it('does not build the image for a website release', () => { + expect(evaluateReleaseGate('nostream-website@1.3.1')).to.equal(false) + }) + + // A published release is immutable, so the build that publishes its image + // cannot be retried through the same event. The dispatch path is what makes a + // failed build recoverable; the step's own range check keeps it from building + // a non-relay tag, since a dispatch carries no release tag at all. + it('accepts a dispatched run so a failed build can be retried', () => { + expect(evaluateReleaseGate('', true)).to.equal(true) + }) + + // The deploy path pulls `:latest`, so the release must actually push it. + it('tags a stable release with its version and latest', () => { + expect(imageTags(releaseWorkflow, 'nostream@3.7.0', false)).to.deep.equal(['3.7.0', 'latest']) + }) + + // `latest` is the tag both deploy stacks pull, so a prerelease must not move + // it onto a test build. + it('does not move latest for a prerelease', () => { + expect(imageTags(releaseWorkflow, 'nostream@3.8.0-rc.1', true)).to.deep.equal(['3.8.0-rc.1']) + }) + + // Releases cut before the changesets migration are tagged `vX.Y.Z`, and a host + // pinned to that form must still resolve after one is republished. + it('keeps the v-prefixed tag for a pre-changesets release', () => { + expect(imageTags(releaseWorkflow, 'v3.4.1', false)).to.deep.equal(['3.4.1', 'v3.4.1', 'latest']) + }) + + // The defect this replaced: `nostream@3.7.0` is a release tag, not a Docker + // tag, and `@` is illegal in one. It must fail here, by name, rather than at + // buildx as `invalid reference format` after the image has been built. + it('refuses a release tag whose version is not a legal Docker tag', () => { + expect(() => imageTags(releaseWorkflow, 'nostream@3.7.0+build', false)).to.throw(/not a legal Docker tag/) + }) + + // A published release is immutable, so a failed build has to be re-runnable + // by hand — that is what workflow_dispatch exists for, and it carries no + // release flags for the step to read. + it('builds a dispatched tag with the same tags as the release event', () => { + expect(imageTags(releaseWorkflow, 'nostream@3.7.0')).to.deep.equal(['3.7.0', 'latest']) + }) + + // Without the event's flag the prerelease has to be read from the tag, or a + // dispatch of an rc silently moves `latest` onto a test build. + it('reads a prerelease out of a dispatched tag', () => { + expect(imageTags(releaseWorkflow, 'nostream@3.8.0-rc.1')).to.deep.equal(['3.8.0-rc.1']) + }) + + // `github.event.release.tag_name` is empty on a dispatch, so a mistyped input + // is the only thing that would otherwise reach the build. + it('refuses a dispatched tag that is not a relay release', () => { + expect(() => imageTags(releaseWorkflow, 'nostream-website@1.3.2')).to.throw(/not a relay release tag/) + }) + + // The image must be built from the code its tag names. A dispatch starts on a + // branch, so without this the default ref is checked out and that branch's + // HEAD is published as a release version. The expression itself only runs on + // GitHub, so this asserts its shape: the tag form for a dispatch, and the + // event's own ref when no input is present. + it('checks out the release tag rather than the dispatching branch', () => { + const step = (releaseWorkflow.jobs['build-and-push-image'].steps ?? []).find((entry) => entry.with?.ref) + const expression = (step?.with?.ref ?? '').replace(/^\$\{\{\s*/, '').replace(/\s*\}\}$/, '') + expect(expression, 'a dispatch must build the tag it was given').to.include("format('refs/tags/{0}', inputs.tag)") + expect(expression, 'a release event already carries its tag in github.ref').to.include('|| github.ref') + }) + it('builds with the tags the step computed', () => { + const build = (releaseWorkflow.jobs['build-and-push-image'].steps ?? []).find((step) => step.with?.tags) + expect(build?.with?.tags, 'the build must consume the computed tags').to.equal('${{ steps.tags.outputs.tags }}') + }) }) diff --git a/test/unit/controllers/admin/apply-moderation-actions-controller.spec.ts b/test/unit/controllers/admin/apply-moderation-actions-controller.spec.ts new file mode 100644 index 000000000..058851caf --- /dev/null +++ b/test/unit/controllers/admin/apply-moderation-actions-controller.spec.ts @@ -0,0 +1,113 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { PostAdminModerationActionsController } from '../../../../src/controllers/admin/post-moderation-actions-controller' +import { ValidationError } from '../../../../src/utils/admin-moderation' +import * as adminModeration from '../../../../src/utils/admin-moderation' +import * as adminModerationPersist from '../../../../src/utils/admin-moderation-persist' + +chai.use(sinonChai) + +const { expect } = chai + +const baseLimits = { + pubkey: { blacklist: [], whitelist: [] }, + kind: { blacklist: [], whitelist: [1] }, + rateLimitBypass: { pubkeys: [], ipAddresses: [] }, +} + +describe('PostAdminModerationActionsController', () => { + let sandbox: Sinon.SinonSandbox + let outboxRepository: { enqueue: Sinon.SinonStub } + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminModeration, 'readAdminModerationLimits').returns(baseLimits) + sandbox.stub(adminModerationPersist, 'saveModerationLimitsFromRequest').resolves() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns 400 for invalid request bodies', async () => { + const controller = new PostAdminModerationActionsController(outboxRepository as any) + await controller.handleRequest({ body: { action: 'not-real' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(response.send).to.have.been.calledOnceWithExactly({ error: 'Invalid request' }) + expect(adminModerationPersist.saveModerationLimitsFromRequest).to.not.have.been.called + }) + + it('returns 400 when action fields are invalid', async () => { + const controller = new PostAdminModerationActionsController(outboxRepository as any) + await controller.handleRequest({ body: { action: 'ban-pubkey' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(response.send.firstCall.args[0].error).to.be.a('string') + expect(adminModerationPersist.saveModerationLimitsFromRequest).to.not.have.been.called + }) + + it('returns 400 when persistence validation fails', async () => { + sandbox.restore() + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminModeration, 'readAdminModerationLimits').returns(baseLimits) + sandbox + .stub(adminModerationPersist, 'saveModerationLimitsFromRequest') + .rejects(new ValidationError('Validation failed', [{ path: 'limits.event.pubkey.blacklist', message: 'bad' }])) + + const controller = new PostAdminModerationActionsController(outboxRepository as any) + await controller.handleRequest( + { body: { action: 'ban-pubkey', pubkey: 'b'.repeat(64) }, ip: '127.0.0.1' } as any, + response as any, + ) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(response.send.firstCall.args[0].error).to.equal('Validation failed') + }) + + it('applies ban-pubkey and persists limits', async () => { + const controller = new PostAdminModerationActionsController(outboxRepository as any) + const pubkey = 'c'.repeat(64) + const savedLimits = { + ...baseLimits, + pubkey: { blacklist: [pubkey], whitelist: [] }, + } + sandbox.restore() + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminModeration, 'readAdminModerationLimits') + ;(adminModeration.readAdminModerationLimits as Sinon.SinonStub).onFirstCall().returns(baseLimits) + ;(adminModeration.readAdminModerationLimits as Sinon.SinonStub).onSecondCall().returns(savedLimits) + sandbox.stub(adminModerationPersist, 'saveModerationLimitsFromRequest').resolves() + + await controller.handleRequest( + { body: { action: 'ban-pubkey', pubkey }, ip: '127.0.0.1' } as any, + response as any, + ) + + expect(adminModerationPersist.saveModerationLimitsFromRequest).to.have.been.calledOnce + expect(response.status).to.have.been.calledOnceWithExactly(200) + expect(response.send.firstCall.args[0].ok).to.equal(true) + expect(response.send.firstCall.args[0].limits.pubkey.blacklist).to.include(pubkey) + }) +}) diff --git a/test/unit/controllers/admin/get-moderation-limits-controller.spec.ts b/test/unit/controllers/admin/get-moderation-limits-controller.spec.ts new file mode 100644 index 000000000..515667db0 --- /dev/null +++ b/test/unit/controllers/admin/get-moderation-limits-controller.spec.ts @@ -0,0 +1,44 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { GetAdminModerationLimitsController } from '../../../../src/controllers/admin/get-moderation-limits-controller' +import * as adminModeration from '../../../../src/utils/admin-moderation' + +chai.use(sinonChai) + +const { expect } = chai + +describe('GetAdminModerationLimitsController', () => { + let sandbox: Sinon.SinonSandbox + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + const limits = { + pubkey: { blacklist: ['a'.repeat(64)], whitelist: [] }, + kind: { blacklist: [], whitelist: [1] }, + rateLimitBypass: { pubkeys: [], ipAddresses: ['127.0.0.1'] }, + } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + sandbox.stub(adminModeration, 'readAdminModerationLimits').returns(limits) + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns current moderation limits', async () => { + const controller = new GetAdminModerationLimitsController() + await controller.handleRequest({} as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(200) + expect(response.setHeader).to.have.been.calledWith('content-type', 'application/json') + expect(response.send).to.have.been.calledOnceWithExactly({ limits }) + }) +}) diff --git a/test/unit/controllers/admin/get-moderation-stats-controller.spec.ts b/test/unit/controllers/admin/get-moderation-stats-controller.spec.ts new file mode 100644 index 000000000..66c90223c --- /dev/null +++ b/test/unit/controllers/admin/get-moderation-stats-controller.spec.ts @@ -0,0 +1,134 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import * as RedisAdapterModule from '../../../../src/adapters/redis-adapter' +import { GetAdminModerationStatsController } from '../../../../src/controllers/admin/get-moderation-stats-controller' +import * as cacheClient from '../../../../src/cache/client' +import * as dbClient from '../../../../src/database/client' +import { + parseModerationStatsLimit, + parseModerationStatsWindowHours, +} from '../../../../src/utils/admin-moderation-stats' +import * as adminModerationStats from '../../../../src/utils/admin-moderation-stats' +import * as moderationRateLimitStats from '../../../../src/utils/moderation-rate-limit-stats' + +chai.use(sinonChai) + +const { expect } = chai + +describe('GetAdminModerationStatsController', () => { + let sandbox: Sinon.SinonSandbox + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(dbClient, 'getReadReplicaDbClient').returns({} as any) + sandbox.stub(adminModerationStats, 'queryTopPublishingPubkeys').resolves([ + { pubkey: 'd'.repeat(64), eventCount: 4 }, + ]) + sandbox.stub(adminModerationStats, 'queryTopClientIps').resolves([{ ip: '127.0.0.1', eventCount: 4 }]) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns stats without rate-limit hits when cache is not configured', async () => { + sandbox.stub(cacheClient, 'isCacheConfigured').returns(false) + + const controller = new GetAdminModerationStatsController() + await controller.handleRequest({ query: { windowHours: '168', limit: '10' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(200) + const payload = response.send.firstCall.args[0] + expect(payload.windowHours).to.equal(168) + expect(payload.topPublishingPubkeys).to.have.length(1) + expect(payload.rateLimitHits).to.deep.equal({ pubkeys: [], ips: [] }) + }) + + it('includes rate-limit hits when cache is configured', async () => { + sandbox.stub(cacheClient, 'isCacheConfigured').returns(true) + sandbox.stub(cacheClient, 'getCacheClient').returns({} as any) + sandbox.stub(RedisAdapterModule, 'RedisAdapter').callsFake(() => ({} as any)) + sandbox.stub(moderationRateLimitStats, 'listModerationRateLimitHits').resolves({ + pubkeys: [{ key: 'e'.repeat(64), hits: 2 }], + ips: [{ key: '127.0.0.1', hits: 2 }], + }) + + const controller = new GetAdminModerationStatsController() + await controller.handleRequest({ query: {} } as any, response as any) + + expect(moderationRateLimitStats.listModerationRateLimitHits).to.have.been.calledOnce + expect(response.send.firstCall.args[0].rateLimitHits.pubkeys).to.have.length(1) + }) + + it('returns 400 for invalid windowHours', async () => { + const controller = new GetAdminModerationStatsController() + await controller.handleRequest({ query: { windowHours: '0' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(adminModerationStats.queryTopPublishingPubkeys).to.not.have.been.called + }) + + + it('returns 500 when stats queries fail unexpectedly', async () => { + sandbox.restore() + sandbox = Sinon.createSandbox() + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(dbClient, 'getReadReplicaDbClient').returns({} as any) + sandbox.stub(cacheClient, 'isCacheConfigured').returns(false) + sandbox.stub(adminModerationStats, 'queryTopPublishingPubkeys').rejects(new Error('db exploded')) + sandbox.stub(adminModerationStats, 'queryTopClientIps').resolves([]) + + const controller = new GetAdminModerationStatsController() + await controller.handleRequest({ query: { windowHours: '24' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(500) + }) + it('returns 504 when stats queries time out', async () => { + sandbox.restore() + sandbox = Sinon.createSandbox() + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(dbClient, 'getReadReplicaDbClient').returns({} as any) + sandbox.stub(cacheClient, 'isCacheConfigured').returns(false) + sandbox.stub(adminModerationStats, 'queryTopPublishingPubkeys').rejects(new Error('Query timeout')) + sandbox.stub(adminModerationStats, 'queryTopClientIps').resolves([]) + + const controller = new GetAdminModerationStatsController() + await controller.handleRequest({ query: { windowHours: '24' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(504) + }) +}) + +describe('parseModerationStatsWindowHours', () => { + it('defaults when windowHours is omitted', () => { + expect(parseModerationStatsWindowHours(undefined)).to.deep.equal({ hours: 168 }) + }) + + it('rejects out-of-range values', () => { + expect(parseModerationStatsWindowHours('0').error).to.be.a('string') + expect(parseModerationStatsWindowHours('9999').error).to.be.a('string') + }) +}) + +describe('parseModerationStatsLimit', () => { + it('defaults invalid limits to 20', () => { + expect(parseModerationStatsLimit(undefined)).to.equal(20) + expect(parseModerationStatsLimit('abc')).to.equal(20) + }) +}) diff --git a/test/unit/controllers/admin/get-network-health-history-controller.spec.ts b/test/unit/controllers/admin/get-network-health-history-controller.spec.ts new file mode 100644 index 000000000..bcf86cd14 --- /dev/null +++ b/test/unit/controllers/admin/get-network-health-history-controller.spec.ts @@ -0,0 +1,82 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { Settings } from '../../../../src/@types/settings' +import { RelayProbeRunSnapshot } from '../../../../src/@types/relay-probe-snapshot' +import { + GetAdminNetworkHealthHistoryController, + parseNetworkHealthHistoryLimit, +} from '../../../../src/controllers/admin/get-network-health-history-controller' +import { RelayProbeHistoryStore } from '../../../../src/utils/relay-probe-history' + +chai.use(sinonChai) + +const { expect } = chai + +describe('GetAdminNetworkHealthHistoryController', () => { + let historyStore: Sinon.SinonStubbedInstance + let controller: GetAdminNetworkHealthHistoryController + let response: { + status: Sinon.SinonStub + setHeader: Sinon.SinonStub + send: Sinon.SinonStub + } + + const settings = { + info: { relay_url: 'wss://relay.example.com/' }, + nip66: { enabled: true, targets: [] }, + } as Settings + + const snapshot: RelayProbeRunSnapshot = { + runAt: '2026-01-01T00:00:00.000Z', + targets: ['wss://relay.example.com'], + results: [], + status: 'ok', + } + + beforeEach(() => { + historyStore = { + append: Sinon.stub(), + list: Sinon.stub().resolves([snapshot]), + } as any + + controller = new GetAdminNetworkHealthHistoryController(historyStore, () => settings) + + response = { + status: Sinon.stub().returnsThis(), + setHeader: Sinon.stub().returnsThis(), + send: Sinon.stub().returnsThis(), + } + }) + + it('returns bounded probe history newest first', async () => { + await controller.handleRequest({ query: { limit: '5' } } as any, response as any) + + expect(historyStore.list).to.have.been.calledOnce + expect(historyStore.list.firstCall.args[0]).to.equal(5) + expect(historyStore.list.firstCall.args[1]).to.deep.equal({ maxRuns: 168, maxAgeHours: undefined }) + expect(response.status).to.have.been.calledOnceWithExactly(200) + const payload = response.send.firstCall.args[0] as { runs: RelayProbeRunSnapshot[] } + expect(payload.runs).to.have.length(1) + expect(payload.runs[0].runAt).to.equal(snapshot.runAt) + }) + + it('rejects invalid limit query values', async () => { + await controller.handleRequest({ query: { limit: '0' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(historyStore.list).not.to.have.been.called + }) +}) + +describe('parseNetworkHealthHistoryLimit', () => { + it('defaults when limit is omitted', () => { + expect(parseNetworkHealthHistoryLimit(undefined)).to.equal(24) + }) + + it('rejects non-positive limits', () => { + expect(parseNetworkHealthHistoryLimit('abc')).to.equal(null) + expect(parseNetworkHealthHistoryLimit(0)).to.equal(null) + }) +}) diff --git a/test/unit/controllers/admin/patch-moderation-limits-controller.spec.ts b/test/unit/controllers/admin/patch-moderation-limits-controller.spec.ts new file mode 100644 index 000000000..4d7a2c48f --- /dev/null +++ b/test/unit/controllers/admin/patch-moderation-limits-controller.spec.ts @@ -0,0 +1,101 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { PatchAdminModerationLimitsController } from '../../../../src/controllers/admin/patch-moderation-limits-controller' +import { ValidationError } from '../../../../src/utils/admin-moderation' +import * as adminModeration from '../../../../src/utils/admin-moderation' +import * as adminModerationPersist from '../../../../src/utils/admin-moderation-persist' + +chai.use(sinonChai) + +const { expect } = chai + +const baseLimits = { + pubkey: { blacklist: [], whitelist: [] }, + kind: { blacklist: [], whitelist: [1] }, + rateLimitBypass: { pubkeys: [], ipAddresses: [] }, +} + +describe('PatchAdminModerationLimitsController', () => { + let sandbox: Sinon.SinonSandbox + let outboxRepository: { enqueue: Sinon.SinonStub } + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminModeration, 'readAdminModerationLimits').returns(baseLimits) + sandbox.stub(adminModerationPersist, 'saveModerationLimitsFromRequest').resolves() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns 400 for invalid request bodies', async () => { + const controller = new PatchAdminModerationLimitsController(outboxRepository as any) + await controller.handleRequest({ body: { pubkey: 'not-an-object' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(response.send).to.have.been.calledOnceWithExactly({ error: 'Invalid request' }) + }) + + it('returns 400 when persistence validation fails', async () => { + sandbox.restore() + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminModeration, 'readAdminModerationLimits').returns(baseLimits) + sandbox + .stub(adminModerationPersist, 'saveModerationLimitsFromRequest') + .rejects(new ValidationError('Validation failed', [{ path: 'limits.event.kind.whitelist', message: 'bad' }])) + + const controller = new PatchAdminModerationLimitsController(outboxRepository as any) + await controller.handleRequest( + { body: { kind: { whitelist: [1, 2] } }, ip: '127.0.0.1' } as any, + response as any, + ) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(response.send.firstCall.args[0].error).to.equal('Validation failed') + expect(response.send.firstCall.args[0].issues).to.be.an('array').that.is.not.empty + }) + + it('merges patches and persists limits', async () => { + const controller = new PatchAdminModerationLimitsController(outboxRepository as any) + const pubkey = 'c'.repeat(64) + const savedLimits = { + ...baseLimits, + pubkey: { blacklist: [pubkey], whitelist: [] }, + } + sandbox.restore() + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminModeration, 'readAdminModerationLimits').returns(savedLimits) + sandbox.stub(adminModerationPersist, 'saveModerationLimitsFromRequest').resolves() + + await controller.handleRequest( + { body: { pubkey: { blacklist: [pubkey] } }, ip: '127.0.0.1' } as any, + response as any, + ) + + expect(adminModerationPersist.saveModerationLimitsFromRequest).to.have.been.calledOnce + expect(response.status).to.have.been.calledOnceWithExactly(200) + expect(response.send.firstCall.args[0].limits.pubkey.blacklist).to.deep.equal([pubkey]) + }) +}) diff --git a/test/unit/controllers/invoices/get-invoice-controller.spec.ts b/test/unit/controllers/invoices/get-invoice-controller.spec.ts index 9b76c06e7..96cea8917 100644 --- a/test/unit/controllers/invoices/get-invoice-controller.spec.ts +++ b/test/unit/controllers/invoices/get-invoice-controller.spec.ts @@ -98,7 +98,9 @@ describe('GetInvoiceController', () => { describe('when payments and admission fee are enabled', () => { beforeEach(() => { createSettingsStub.returns(enabledPaymentsSettings) - getTemplateStub.returns('{{name}}|{{path_prefix}}|{{processor_json}}|{{amount}}|{{nonce}}') + getTemplateStub.returns( + '{{name}}|{{path_prefix}}|{{processor_json}}|{{pay_label}}|{{fee_description_html}}|{{plan_fields_html}}|{{nonce}}', + ) }) it('loads the get-invoice template', async () => { @@ -121,7 +123,9 @@ describe('GetInvoiceController', () => { expect(sent).to.not.include('{{name}}') expect(sent).to.not.include('{{path_prefix}}') expect(sent).to.not.include('{{processor_json}}') - expect(sent).to.not.include('{{amount}}') + expect(sent).to.not.include('{{pay_label}}') + expect(sent).to.not.include('{{fee_description_html}}') + expect(sent).to.not.include('{{plan_fields_html}}') expect(sent).to.not.include('{{nonce}}') }) @@ -141,11 +145,11 @@ describe('GetInvoiceController', () => { it('renders amount in sats (msats / 1000)', async () => { // 21000 msats → 21 sats - getTemplateStub.returns('{{amount}}') + getTemplateStub.returns('{{pay_label}}') await controller.handleRequest({} as any, res) - expect(res.send.firstCall.args[0]).to.equal('21') + expect(res.send.firstCall.args[0]).to.equal('Pay 21 sats') }) it('safe-serializes processor for inline script', async () => { @@ -179,4 +183,80 @@ describe('GetInvoiceController', () => { expect(res.send.firstCall.args[0]).to.equal('/nostream/invoices') }) }) + + describe('when subscription plans are configured', () => { + const plansSettings = (overrides: any = {}) => ({ + ...enabledPaymentsSettings, + payments: { + ...enabledPaymentsSettings.payments, + subscriptionPlans: [ + { id: 'basic', amount: 5000000n, intervalSeconds: 2592000, graceSeconds: 259200 }, + { id: 'vip<1>', amount: 25000000n, intervalSeconds: 7776000, graceSeconds: 0, description: 'all "perks"' }, + ], + ...overrides, + }, + }) + + beforeEach(() => { + createSettingsStub.returns(plansSettings()) + getTemplateStub.returns('{{fee_description_html}}|{{plan_fields_html}}|{{pay_label}}') + }) + + it('lists every plan as a radio option with its price and period, the first one selected', async () => { + await controller.handleRequest({} as any, res) + + const sent = res.send.firstCall.args[0] as string + expect(sent.match(/name="planId"/g)).to.have.length(2) + expect(sent).to.include('value="basic" checked') + expect(sent).to.include('basic: 5000 sats per 30 days') + expect(sent).to.include('25000 sats per 90 days') + }) + + it('escapes plan ids and descriptions', async () => { + await controller.handleRequest({} as any, res) + + const sent = res.send.firstCall.args[0] as string + expect(sent).to.not.include('vip<1>') + expect(sent).to.include('vip<1>') + expect(sent).to.not.include('"perks"') + }) + + it('describes a subscription and asks to pay without a fixed amount', async () => { + await controller.handleRequest({} as any, res) + + const sent = res.send.firstCall.args[0] as string + expect(sent).to.include('requires a subscription') + expect(sent).to.not.include('one-time admission fee') + expect(sent.endsWith('|Pay')).to.equal(true) + }) + + it('is available without an admission fee, since a plan is what admits a user', async () => { + createSettingsStub.returns( + plansSettings({ feeSchedules: { admission: [{ enabled: false, amount: 21000, whitelists: {} }] } }), + ) + + await controller.handleRequest({} as any, res) + + expect(res.status).to.have.been.calledWith(200) + }) + + it('responds with 404 while payments are disabled', async () => { + createSettingsStub.returns(plansSettings({ enabled: false })) + + await controller.handleRequest({} as any, res) + + expect(res.status).to.have.been.calledWith(404) + }) + + it('keeps the admission page when no plan is configured', async () => { + createSettingsStub.returns(plansSettings({ subscriptionPlans: [] })) + + await controller.handleRequest({} as any, res) + + const sent = res.send.firstCall.args[0] as string + expect(sent).to.include('one-time admission fee') + expect(sent).to.not.include('planId') + expect(sent.endsWith('|Pay 21 sats')).to.equal(true) + }) + }) }) diff --git a/test/unit/controllers/invoices/post-invoice-controller.spec.ts b/test/unit/controllers/invoices/post-invoice-controller.spec.ts index 9607c0be9..b53840aa6 100644 --- a/test/unit/controllers/invoices/post-invoice-controller.spec.ts +++ b/test/unit/controllers/invoices/post-invoice-controller.spec.ts @@ -44,7 +44,7 @@ const makeController = ( }), }, overrides.settings ?? (() => baseSettings), - overrides.rateLimiter ?? (() => ({ hit: sinon.stub().resolves(false) })), + overrides.rateLimiter ?? (() => ({ hit: sinon.stub().resolves({ limited: false }) })), ) } @@ -80,7 +80,7 @@ describe('PostInvoiceController', () => { describe('rate limiting', () => { it('returns 429 when rate limited', async () => { - const rateLimiter = { hit: sinon.stub().resolves(true) } + const rateLimiter = { hit: sinon.stub().resolves({ limited: true }) } const settings = () => ({ ...baseSettings, limits: { @@ -222,6 +222,134 @@ describe('PostInvoiceController', () => { }) }) + describe('subscription plans', () => { + const plan = { id: 'basic', amount: 5000000n, intervalSeconds: 2592000, graceSeconds: 259200 } + const plansSettings = () => ({ + ...baseSettings, + payments: { ...baseSettings.payments, subscriptionPlans: [plan, { ...plan, id: 'premium', amount: 9000000n }] }, + }) + const planBody = { ...validBody, planId: 'basic' } + const stubbedPaymentsService = () => ({ + createInvoice: sinon + .stub() + .resolves({ id: 'inv-ref-123', bolt11: 'lnbc1...', expiresAt: new Date('2030-01-01') }), + }) + + it('creates an invoice for the plan price and tags it with the plan', async () => { + const paymentsService = stubbedPaymentsService() + const controller = makeController({ settings: plansSettings, paymentsService }) + const res = makeRes() + + await controller.handleRequest({ body: planBody } as any, res) + + expect(res.status).to.have.been.calledWith(200) + const [pubkey, amount, description, planId] = paymentsService.createInvoice.firstCall.args + expect(pubkey).to.equal(VALID_PUBKEY) + expect(amount).to.equal(5000000n) + expect(description).to.contain('basic') + expect(planId).to.equal('basic') + }) + + it('keeps the chosen plan in the retry form of an expired invoice', async () => { + getTemplateStub.returns('{{plan_id_html}}') + const controller = makeController({ settings: plansSettings }) + const res = makeRes() + + await controller.handleRequest({ body: { ...validBody, planId: 'premium' } } as any, res) + + expect(res.send).to.have.been.calledWith('premium') + }) + + it('leaves the plan empty in the retry form when no plan is configured', async () => { + getTemplateStub.returns('[{{plan_id_html}}]') + const controller = makeController() + const res = makeRes() + + await controller.handleRequest({ body: validBody } as any, res) + + expect(res.send).to.have.been.calledWith('[]') + }) + + it('charges the price of the chosen plan', async () => { + const paymentsService = stubbedPaymentsService() + const controller = makeController({ settings: plansSettings, paymentsService }) + + await controller.handleRequest({ body: { ...validBody, planId: 'premium' } } as any, makeRes()) + + expect(paymentsService.createInvoice.firstCall.args[1]).to.equal(9000000n) + }) + + it('returns 400 when no plan is chosen, since the amount selects the plan', async () => { + const paymentsService = stubbedPaymentsService() + const controller = makeController({ settings: plansSettings, paymentsService }) + const res = makeRes() + + await controller.handleRequest({ body: validBody } as any, res) + + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('Invalid plan') + expect(paymentsService.createInvoice).not.to.have.been.called + }) + + it('returns 400 for an unknown plan or a plan id that is not a string', async () => { + const paymentsService = stubbedPaymentsService() + const controller = makeController({ settings: plansSettings, paymentsService }) + + for (const planId of ['gold', 'grandfathered', 42, ['basic'], { id: 'basic' }]) { + const res = makeRes() + + await controller.handleRequest({ body: { ...validBody, planId } } as any, res) + + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('Invalid plan') + } + expect(paymentsService.createInvoice).not.to.have.been.called + }) + + it('lets an admitted user renew or change plan', async () => { + const userRepository = { findByPubkey: sinon.stub().resolves({ isAdmitted: true, balance: 99999 }) } + const controller = makeController({ settings: plansSettings, userRepository }) + const res = makeRes() + + await controller.handleRequest({ body: planBody } as any, res) + + expect(res.status).to.have.been.calledWith(200) + }) + + it('needs no admission fee schedule', async () => { + const settings = () => ({ + ...plansSettings(), + payments: { ...plansSettings().payments, feeSchedules: { admission: [] } }, + }) + const controller = makeController({ settings }) + const res = makeRes() + + await controller.handleRequest({ body: planBody } as any, res) + + expect(res.status).to.have.been.calledWith(200) + }) + + it('still requires the ToS to be accepted', async () => { + const controller = makeController({ settings: plansSettings }) + const res = makeRes() + + await controller.handleRequest({ body: { ...planBody, tosAccepted: 'no' } } as any, res) + + expect(res.send).to.have.been.calledWith('ToS agreement: not accepted') + }) + + it('ignores a plan id while plans are not configured, keeping the admission fee', async () => { + const paymentsService = stubbedPaymentsService() + const controller = makeController({ paymentsService }) + + await controller.handleRequest({ body: { ...validBody, planId: 'basic' } } as any, makeRes()) + + const [, amount, , planId] = paymentsService.createInvoice.firstCall.args + expect(amount).to.equal(21000n) + expect(planId).to.equal(undefined) + }) + }) + describe('invoice creation', () => { it('returns 500 when the payments service throws', async () => { const paymentsService = { diff --git a/test/unit/controllers/subscriptions/get-subscription-status-controller.spec.ts b/test/unit/controllers/subscriptions/get-subscription-status-controller.spec.ts new file mode 100644 index 000000000..9601b327e --- /dev/null +++ b/test/unit/controllers/subscriptions/get-subscription-status-controller.spec.ts @@ -0,0 +1,393 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) +const { expect } = chai + +import { GetSubscriptionStatusController } from '../../../../src/controllers/subscriptions/get-subscription-status-controller' +import { SubscriptionStatus, UserSubscription } from '../../../../src/@types/user-subscription' +import { toBech32 } from '../../../../src/utils/transform' + +const PUBKEY = 'a'.repeat(64) +const DAY = 86_400_000 + +const baseSettings = (overrides: any = {}) => ({ + info: { name: 'Test Relay', relay_url: 'wss://relay.example.com' }, + payments: { + enabled: true, + subscriptionPlans: [{ id: 'basic', amount: 5000000n, intervalSeconds: 2592000, graceSeconds: 259200 }], + }, + limits: {}, + network: { remoteIpHeader: 'x-forwarded-for' }, + ...overrides, +}) + +const subscription = (overrides: Partial = {}): UserSubscription => ({ + pubkey: PUBKEY, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: new Date(Date.now() - 10 * DAY), + currentPeriodEnd: new Date(Date.now() + 20 * DAY), + graceEndsAt: new Date(Date.now() + 23 * DAY), + cancelAtPeriodEnd: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}) + +const makeRes = (): any => ({ + status: sinon.stub().returnsThis(), + setHeader: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), +}) + +const makeReq = (query: any = { pubkey: PUBKEY }): any => ({ + query, + headers: {}, + connection: { remoteAddress: '1.2.3.4' }, + socket: { remoteAddress: '1.2.3.4' }, +}) + +describe('GetSubscriptionStatusController', () => { + let repository: { findByPubkey: sinon.SinonStub } + let rateLimiterHit: sinon.SinonStub + let currentSettings: any + + const makeController = () => + new GetSubscriptionStatusController( + repository as any, + () => currentSettings, + () => ({ hit: rateLimiterHit }), + ) + + beforeEach(() => { + repository = { findByPubkey: sinon.stub().resolves(subscription()) } + rateLimiterHit = sinon.stub().resolves({ limited: false }) + currentSettings = baseSettings() + }) + + describe('a subscriber', () => { + it('reports the plan, status and when the period and access end', async () => { + const current = subscription() + repository.findByPubkey.resolves(current) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(200) + expect(res.setHeader).to.have.been.calledWith('content-type', 'application/json; charset=utf8') + expect(res.send).to.have.been.calledOnceWithExactly({ + pubkey: PUBKEY, + planId: 'basic', + status: 'active', + active: true, + currentPeriodEnd: current.currentPeriodEnd!.toISOString(), + accessEndsAt: current.graceEndsAt!.toISOString(), + }) + expect(repository.findByPubkey).to.have.been.calledOnceWithExactly(PUBKEY) + }) + + it('is not active once the subscription expired', async () => { + repository.findByPubkey.resolves( + subscription({ + status: SubscriptionStatus.EXPIRED, + currentPeriodEnd: new Date(Date.now() - 5 * DAY), + graceEndsAt: null, + }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0]).to.include({ status: 'expired', active: false }) + }) + + it('reports when access really ended for an expired subscription: the period end plus the grace of its plan', async () => { + const periodEnd = new Date(Date.now() - 5 * DAY) + repository.findByPubkey.resolves( + subscription({ status: SubscriptionStatus.EXPIRED, currentPeriodEnd: periodEnd, graceEndsAt: null }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + // the plan allows 259200 seconds, three days, of grace + expect(res.send.firstCall.args[0].accessEndsAt).to.equal(new Date(periodEnd.getTime() + 3 * DAY).toISOString()) + }) + + it('falls back to the period end for an expired subscription whose plan was removed', async () => { + const periodEnd = new Date(Date.now() - 5 * DAY) + repository.findByPubkey.resolves( + subscription({ + planId: 'retired', + status: SubscriptionStatus.EXPIRED, + currentPeriodEnd: periodEnd, + graceEndsAt: null, + }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0].accessEndsAt).to.equal(periodEnd.toISOString()) + }) + + it('never reports the placeholder date of the write gate', async () => { + repository.findByPubkey.resolves( + subscription({ status: SubscriptionStatus.EXPIRED, currentPeriodEnd: new Date(Date.now() - 5 * DAY) }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0].accessEndsAt).to.not.contain('1970') + }) + + it('is active in the grace period, whatever the stored status says', async () => { + repository.findByPubkey.resolves( + subscription({ + status: SubscriptionStatus.PAST_DUE, + currentPeriodEnd: new Date(Date.now() - DAY), + graceEndsAt: new Date(Date.now() + 2 * DAY), + }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0]).to.include({ status: 'past_due', active: true }) + }) + + it('is not active when the period and grace have passed but the sweep has not run yet', async () => { + repository.findByPubkey.resolves( + subscription({ + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: new Date(Date.now() - 5 * DAY), + graceEndsAt: new Date(Date.now() - DAY), + }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0].active).to.equal(false) + }) + + it('reports no end for a subscription that never ends', async () => { + repository.findByPubkey.resolves( + subscription({ planId: 'grandfathered', currentPeriodEnd: null, graceEndsAt: null }), + ) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.send.firstCall.args[0]).to.include({ + planId: 'grandfathered', + active: true, + currentPeriodEnd: null, + accessEndsAt: null, + }) + }) + }) + + describe('a pubkey without a subscription', () => { + it('reports no plan and no status', async () => { + repository.findByPubkey.resolves(undefined) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(200) + expect(res.send).to.have.been.calledOnceWithExactly({ + pubkey: PUBKEY, + planId: null, + status: null, + active: false, + currentPeriodEnd: null, + accessEndsAt: null, + }) + }) + }) + + describe('the pubkey', () => { + it('accepts an npub in upper case, which bech32 allows', async () => { + const res = makeRes() + + await makeController().handleRequest(makeReq({ pubkey: toBech32('npub')(PUBKEY).toUpperCase() }), res) + + expect(res.status).to.have.been.calledWith(200) + expect(repository.findByPubkey).to.have.been.calledOnceWithExactly(PUBKEY) + }) + + it('accepts an npub and looks it up by its hex', async () => { + const res = makeRes() + + await makeController().handleRequest(makeReq({ pubkey: toBech32('npub')(PUBKEY) }), res) + + expect(res.status).to.have.been.calledWith(200) + expect(repository.findByPubkey).to.have.been.calledOnceWithExactly(PUBKEY) + }) + + for (const [label, pubkey] of [ + ['missing', undefined], + ['too short', 'abc'], + ['not hex', 'z'.repeat(64)], + ['upper case hex', 'A'.repeat(64)], + ['an invalid npub', 'npub1invalid'], + ['an npub of 31 bytes', toBech32('npub')('a'.repeat(62))], + ['an npub of 33 bytes', toBech32('npub')('a'.repeat(66))], + ['an nsec', toBech32('nsec')(PUBKEY)], + ['an npub in mixed case', toBech32('npub')(PUBKEY).replace(/[a-z](?=[a-z0-9]*$)/, (c) => c.toUpperCase())], + ['not a string', ['a'.repeat(64)]], + ] as Array<[string, unknown]>) { + it(`returns 400 when it is ${label}`, async () => { + const res = makeRes() + + await makeController().handleRequest(makeReq({ pubkey }), res) + + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('Invalid pubkey') + expect(repository.findByPubkey).not.to.have.been.called + }) + } + + it('returns 400 when there is no query at all', async () => { + const res = makeRes() + + await makeController().handleRequest({ ...makeReq(), query: undefined }, res) + + expect(res.status).to.have.been.calledWith(400) + }) + }) + + describe('when subscriptions are not in use', () => { + it('returns 404 while no plan is configured', async () => { + currentSettings = baseSettings({ payments: { enabled: true, subscriptionPlans: [] } }) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(404) + expect(repository.findByPubkey).not.to.have.been.called + }) + + it('returns 404 while payments are off', async () => { + currentSettings = baseSettings() + currentSettings.payments.enabled = false + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(404) + }) + }) + + describe('cross-origin reads', () => { + const expectCors = (res: any) => { + expect(res.setHeader).to.have.been.calledWith('access-control-allow-origin', '*') + expect(res.setHeader).to.have.been.calledWith('access-control-allow-headers', '*') + expect(res.setHeader).to.have.been.calledWith('access-control-allow-methods', 'GET, OPTIONS') + } + + it('lets a page from another origin read a subscription', async () => { + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(200) + expectCors(res) + }) + + it('lets it read an answer for a pubkey with no subscription', async () => { + repository.findByPubkey.resolves(undefined) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expectCors(res) + }) + + it('adds the headers to a 400, so a client can tell the pubkey was refused', async () => { + const res = makeRes() + + await makeController().handleRequest(makeReq({ pubkey: 'abc' }), res) + + expect(res.status).to.have.been.calledWith(400) + expectCors(res) + }) + + it('adds the headers to a 404 while plans are off', async () => { + currentSettings = baseSettings({ payments: { enabled: true, subscriptionPlans: [] } }) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(404) + expectCors(res) + }) + + it('adds the headers to a 429, so a client can tell it was rate limited', async () => { + currentSettings = baseSettings({ + limits: { invoice: { rateLimits: [{ rate: 2, period: 60000 }], ipWhitelist: [] } }, + }) + rateLimiterHit.resolves({ limited: true }) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(429) + expectCors(res) + }) + }) + + describe('rate limiting', () => { + const limited = () => + baseSettings({ limits: { invoice: { rateLimits: [{ rate: 2, period: 60000 }], ipWhitelist: [] } } }) + + it('returns 429 when the invoice limits are exceeded, without reading anything', async () => { + currentSettings = limited() + rateLimiterHit.resolves({ limited: true }) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(429) + expect(repository.findByPubkey).not.to.have.been.called + }) + + it('counts the caller under its own key and the limits of the invoice endpoints', async () => { + currentSettings = limited() + + await makeController().handleRequest(makeReq(), makeRes()) + + expect(rateLimiterHit).to.have.been.calledOnceWithExactly('1.2.3.4:subscription-status:60000', 1, { + period: 60000, + rate: 2, + }) + }) + + it('does not limit a whitelisted address', async () => { + currentSettings = baseSettings({ + limits: { invoice: { rateLimits: [{ rate: 2, period: 60000 }], ipWhitelist: ['1.2.3.4'] } }, + }) + rateLimiterHit.resolves({ limited: true }) + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(200) + }) + + it('does not limit when no limits are configured', async () => { + const res = makeRes() + + await makeController().handleRequest(makeReq(), res) + + expect(rateLimiterHit).not.to.have.been.called + expect(res.status).to.have.been.calledWith(200) + }) + }) +}) diff --git a/test/unit/factories/admin-moderation-controller-factories.spec.ts b/test/unit/factories/admin-moderation-controller-factories.spec.ts new file mode 100644 index 000000000..c89bad59b --- /dev/null +++ b/test/unit/factories/admin-moderation-controller-factories.spec.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { createGetAdminModerationLimitsController } from '../../../src/factories/controllers/get-admin-moderation-limits-controller-factory' +import { createGetAdminModerationStatsController } from '../../../src/factories/controllers/get-admin-moderation-stats-controller-factory' +import { createPatchAdminModerationLimitsController } from '../../../src/factories/controllers/patch-admin-moderation-limits-controller-factory' +import * as dbClient from '../../../src/database/client' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { createPostAdminModerationActionsController } = require('../../../src/factories/controllers/post-admin-moderation-actions-controller-factory') + +describe('admin moderation controller factories', () => { + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + sandbox.stub(dbClient, 'getMasterDbClient').returns({} as any) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('creates moderation admin controllers', () => { + expect(createGetAdminModerationLimitsController()).to.have.property('handleRequest') + expect(createGetAdminModerationStatsController()).to.have.property('handleRequest') + expect(createPatchAdminModerationLimitsController()).to.have.property('handleRequest') + expect(createPostAdminModerationActionsController()).to.have.property('handleRequest') + }) +}) diff --git a/test/unit/factories/message-handler-factory.spec.ts b/test/unit/factories/message-handler-factory.spec.ts index 6e5374d98..e9c30dd4c 100644 --- a/test/unit/factories/message-handler-factory.spec.ts +++ b/test/unit/factories/message-handler-factory.spec.ts @@ -15,6 +15,9 @@ import { EventMessageHandler } from '../../../src/handlers/event-message-handler import { IWebSocketAdapter } from '../../../src/@types/adapters' import { messageHandlerFactory } from '../../../src/factories/message-handler-factory' import { CountMessageHandler } from '../../../src/handlers/count-message-handler' +import { NegCloseMessageHandler } from '../../../src/handlers/neg-close-message-handler' +import { NegMsgMessageHandler } from '../../../src/handlers/neg-msg-message-handler' +import { NegOpenMessageHandler } from '../../../src/handlers/neg-open-message-handler' import { SubscribeMessageHandler } from '../../../src/handlers/subscribe-message-handler' import { UnsubscribeMessageHandler } from '../../../src/handlers/unsubscribe-message-handler' import * as cacheModule from '../../../src/cache/client' @@ -101,6 +104,24 @@ describe('messageHandlerFactory', () => { expect(factory([message, adapter])).to.be.an.instanceOf(AuthMessageHandler) }) + it('returns NegOpenMessageHandler when given a NEG-OPEN message', () => { + message = [MessageType.NEG_OPEN, 'sync1', {}, '6100000200'] as any + + expect(factory([message, adapter])).to.be.an.instanceOf(NegOpenMessageHandler) + }) + + it('returns NegMsgMessageHandler when given a NEG-MSG message', () => { + message = [MessageType.NEG_MSG, 'sync1', '6100'] as any + + expect(factory([message, adapter])).to.be.an.instanceOf(NegMsgMessageHandler) + }) + + it('returns NegCloseMessageHandler when given a NEG-CLOSE message', () => { + message = [MessageType.NEG_CLOSE, 'sync1'] as any + + expect(factory([message, adapter])).to.be.an.instanceOf(NegCloseMessageHandler) + }) + it('throws when given an invalid message', () => { message = [] as any diff --git a/test/unit/handlers/auth-message-handler.spec.ts b/test/unit/handlers/auth-message-handler.spec.ts index e9b6d1769..b8606ad13 100644 --- a/test/unit/handlers/auth-message-handler.spec.ts +++ b/test/unit/handlers/auth-message-handler.spec.ts @@ -81,6 +81,8 @@ describe('AuthMessageHandler', () => { getChallenge: Sinon.stub().returns(challenge), getAuthenticatedPubkeys: Sinon.stub().returns(new Set()), addAuthenticatedPubkey: Sinon.stub().returns(true), + getPeerPubkey: Sinon.stub().returns(undefined), + setPeerPubkey: Sinon.stub(), } as any as IWebSocketAdapter settingsFactory = Sinon.stub().returns({ @@ -241,5 +243,36 @@ describe('AuthMessageHandler', () => { expect(args[1][2]).to.equal(false) expect(args[1][3]).to.include('auth event already used') }) + + it('marks a configured peer pubkey as the connection peer', async () => { + settingsFactory.returns({ + info: { relay_url: relayUrl }, + peering: { + enabled: true, + peers: [{ pubkey, relayUrl: 'wss://peer.example.com', enabled: true }], + }, + } as Partial) + const message = await createAuthEvent() + + await handler.handleMessage(message) + + expect(webSocket.setPeerPubkey as Sinon.SinonStub).to.have.been.calledOnceWithExactly(pubkey) + }) + + it('does not mark an unconfigured pubkey as a peer', async () => { + settingsFactory.returns({ + info: { relay_url: relayUrl }, + peering: { + enabled: true, + peers: [{ pubkey: getPublicKey('b'.repeat(64)), relayUrl: 'wss://peer.example.com' }], + }, + } as Partial) + const message = await createAuthEvent() + + await handler.handleMessage(message) + + expect(webSocket.setPeerPubkey as Sinon.SinonStub).not.to.have.been.called + expect(emitStub.firstCall.args[1][2]).to.equal(true) + }) }) }) diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 14edc1b2c..f265f8b74 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -17,6 +17,7 @@ import { EventKinds, EventExpirationTimeMetadataKey, EventTags } from '../../../ import { EventMessageHandler } from '../../../src/handlers/event-message-handler' import { IUserRepository } from '../../../src/@types/repositories' import { IWebSocketAdapter } from '../../../src/@types/adapters' +import { SubscriptionStatus, UserSubscription } from '../../../src/@types/user-subscription' import { getCurrentRate, recordEvent, resetAdaptivePowState } from '../../../src/utils/adaptive-pow' import { WebSocketAdapterEvent } from '../../../src/constants/adapter' @@ -91,7 +92,7 @@ describe('EventMessageHandler', () => { webSocket = new EventEmitter() as any webSocket.on(WebSocketAdapterEvent.Message, onMessageSpy) message = [MessageType.EVENT, event] - isRateLimitedStub = sandbox.stub(EventMessageHandler.prototype, 'isRateLimited' as any) + isRateLimitedStub = sandbox.stub(EventMessageHandler.prototype, 'isRateLimited' as any).resolves({ status: 'allowed' }) handler = new EventMessageHandler( webSocket as any, strategyFactoryStub, @@ -103,7 +104,7 @@ describe('EventMessageHandler', () => { }) as any, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) }) @@ -125,7 +126,7 @@ describe('EventMessageHandler', () => { }) it('rejects event if rate-limited with the backoff in ms', async () => { - isRateLimitedStub.resolves(60000) + isRateLimitedStub.resolves({ status: 'limited', retryAfterMs: 60000 }) await handler.handleMessage(message) @@ -140,8 +141,10 @@ describe('EventMessageHandler', () => { expect(strategyFactoryStub).not.to.have.been.called }) - it('rejects event if rate-limited without a backoff when none is known', async () => { - isRateLimitedStub.resolves(true) + // A limiter outage is our fault. Announcing `rate-limited` made every + // connected client back off for it, so the refusal names the error instead. + it('rejects event with an error, not a backoff, when the limiter is unavailable', async () => { + isRateLimitedStub.resolves({ status: 'unavailable' }) await handler.handleMessage(message) @@ -149,7 +152,7 @@ describe('EventMessageHandler', () => { MessageType.OK, event.id, false, - 'rate-limited: slow down', + 'error: rate limiter unavailable', ]) expect(strategyFactoryStub).not.to.have.been.called }) @@ -279,7 +282,7 @@ describe('EventMessageHandler', () => { powSettings, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) canAcceptEventStub.returns('rejected: pow') @@ -298,10 +301,10 @@ describe('EventMessageHandler', () => { powSettings, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) - isRateLimitedStub.resolves(true) + isRateLimitedStub.resolves({ status: 'limited', retryAfterMs: 60000 }) await powHandler.handleMessage(message) @@ -317,7 +320,7 @@ describe('EventMessageHandler', () => { powSettings, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) isEventValidStub.returns(undefined) @@ -377,7 +380,7 @@ describe('EventMessageHandler', () => { () => settings, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) }) @@ -717,7 +720,7 @@ describe('EventMessageHandler', () => { () => settings, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), { getDistance: getDistanceStub } as any, ) @@ -1036,7 +1039,7 @@ describe('EventMessageHandler', () => { }) as any, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) }) @@ -1074,7 +1077,7 @@ describe('EventMessageHandler', () => { event: eventLimits, }, } as any - rateLimiterHitStub = sandbox.stub() + rateLimiterHitStub = sandbox.stub().resolves({ limited: false }) getClientAddressStub = sandbox.stub() webSocket = { getClientAddress: getClientAddressStub, @@ -1097,22 +1100,22 @@ describe('EventMessageHandler', () => { it('fulfills with false if limits setting is not set', async () => { settings.limits = undefined - return expect((handler as any).isRateLimited(event)).to.eventually.be.false + return expect((handler as any).isRateLimited(event)).to.eventually.deep.equal({ status: 'allowed' }) }) it('fulfills with false if event limits setting is not set', async () => { settings.limits.event = undefined - return expect((handler as any).isRateLimited(event)).to.eventually.be.false + return expect((handler as any).isRateLimited(event)).to.eventually.deep.equal({ status: 'allowed' }) }) it('fulfills with false if rate limits setting is not set', async () => { eventLimits.rateLimits = undefined - return expect((handler as any).isRateLimited(event)).to.eventually.be.false + return expect((handler as any).isRateLimited(event)).to.eventually.deep.equal({ status: 'allowed' }) }) it('fulfills with false if rate limits setting is empty', async () => { eventLimits.rateLimits = [] - return expect((handler as any).isRateLimited(event)).to.eventually.be.false + return expect((handler as any).isRateLimited(event)).to.eventually.deep.equal({ status: 'allowed' }) }) it("fulfills with false if event pubkey equals relay's own public key", async () => { @@ -1126,7 +1129,7 @@ describe('EventMessageHandler', () => { const actualResult = await (handler as any).isRateLimited(event) - expect(actualResult).to.be.false + expect(actualResult.status).to.equal('allowed') expect(rateLimiterHitStub).not.to.have.been.called }) @@ -1143,7 +1146,7 @@ describe('EventMessageHandler', () => { const actualResult = await (handler as any).isRateLimited(event) - expect(actualResult).to.be.false + expect(actualResult.status).to.equal('allowed') expect(rateLimiterHitStub).not.to.have.been.called }) @@ -1175,7 +1178,7 @@ describe('EventMessageHandler', () => { const actualResult = await (handler as any).isRateLimited(event) - expect(actualResult).to.be.false + expect(actualResult.status).to.equal('allowed') expect(rateLimiterHitStub).not.to.have.been.called }) @@ -1254,7 +1257,7 @@ describe('EventMessageHandler', () => { }, ] - rateLimiterHitStub.resolves(false) + rateLimiterHitStub.resolves({ limited: false }) const actualResult = await (handler as any).isRateLimited(event) @@ -1266,7 +1269,7 @@ describe('EventMessageHandler', () => { rate: 1, }, ) - expect(actualResult).to.be.false + expect(actualResult.status).to.equal('allowed') }) it('fulfills with the tripped period if rate limited by second rate limit setting', async () => { @@ -1287,8 +1290,8 @@ describe('EventMessageHandler', () => { }, ] - rateLimiterHitStub.onFirstCall().resolves(false) - rateLimiterHitStub.onSecondCall().resolves(true) + rateLimiterHitStub.onFirstCall().resolves({ limited: false }) + rateLimiterHitStub.onSecondCall().resolves({ limited: true, retryAfterMs: 180 }) const actualResult = await (handler as any).isRateLimited(event) @@ -1308,7 +1311,7 @@ describe('EventMessageHandler', () => { rate: 3, }, ) - expect(actualResult).to.equal(180) + expect(actualResult).to.deep.equal({ status: 'limited', retryAfterMs: 180 }) }) it('stops hitting subsequent rate limit windows once one is exceeded', async () => { @@ -1323,12 +1326,12 @@ describe('EventMessageHandler', () => { }, ] - rateLimiterHitStub.onFirstCall().resolves(true) + rateLimiterHitStub.onFirstCall().resolves({ limited: true, retryAfterMs: 60000 }) const actualResult = await (handler as any).isRateLimited(event) expect(rateLimiterHitStub).to.have.been.calledOnce - expect(actualResult).to.equal(60000) + expect(actualResult).to.deep.equal({ status: 'limited', retryAfterMs: 60000 }) }) it('fails closed when the rate limiter backend is unavailable', async () => { @@ -1342,9 +1345,310 @@ describe('EventMessageHandler', () => { const actualResult = await (handler as any).isRateLimited(event) - expect(actualResult).to.be.true + expect(actualResult).to.deep.equal({ status: 'unavailable' }) expect(rateLimiterHitStub).to.have.been.calledOnce }) + + describe('subscription plans', () => { + const DAY = 86_400_000 + const PUBKEY = 'f'.repeat(64) + let subscriptionFindByPubkeyStub: SinonStub + let cacheStub: { getKey: SinonStub; setKey: SinonStub } + let subscriber: Event + + const subscription = (overrides: Partial = {}): UserSubscription => ({ + pubkey: PUBKEY, + planId: 'premium', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: new Date(Date.now() - 10 * DAY), + currentPeriodEnd: new Date(Date.now() + 20 * DAY), + graceEndsAt: new Date(Date.now() + 23 * DAY), + cancelAtPeriodEnd: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) + + beforeEach(() => { + subscriber = { ...event, pubkey: PUBKEY, kind: 1 } + eventLimits.rateLimits = [{ period: 60000, rate: 1 }] + settings.payments = { + enabled: true, + feeSchedules: { admission: [] }, + subscriptionPlans: [ + { + id: 'premium', + amount: 5000000n, + intervalSeconds: 2_592_000, + graceSeconds: 259_200, + limits: { rateLimits: [{ period: 60000, rate: 100 }] }, + }, + { id: 'basic', amount: 1000000n, intervalSeconds: 2_592_000, graceSeconds: 259_200 }, + ], + } as any + rateLimiterHitStub.resolves({ limited: false }) + subscriptionFindByPubkeyStub = sandbox.stub().resolves(subscription()) + cacheStub = { getKey: sandbox.stub().resolves(null), setKey: sandbox.stub().resolves(true) } + handler = new EventMessageHandler( + webSocket, + () => null, + {} as any, + userRepository, + () => settings, + {} as any, + cacheStub as any, + () => ({ hit: rateLimiterHitStub }), + {} as any, + { findByPubkey: subscriptionFindByPubkeyStub } as any, + ) + }) + + it('holds a subscriber to the limits of their plan instead of the global ones, keyed the same way', async () => { + await expect((handler as any).isRateLimited(subscriber)).to.eventually.deep.equal({ status: 'allowed' }) + + expect(rateLimiterHitStub).to.have.been.calledOnceWithExactly(`${PUBKEY}:events:60000`, 1, { + period: 60000, + rate: 100, + }) + }) + + it('holds everyone else to the global limits', async () => { + subscriptionFindByPubkeyStub.resolves(undefined) + + await (handler as any).isRateLimited(subscriber) + + expect(rateLimiterHitStub).to.have.been.calledOnceWithExactly(`${PUBKEY}:events:60000`, 1, { + period: 60000, + rate: 1, + }) + }) + + it('limits a subscriber who tops the plan limit', async () => { + rateLimiterHitStub.resolves({ limited: true }) + + await expect((handler as any).isRateLimited(subscriber)).to.eventually.deep.equal({ status: 'limited', retryAfterMs: 60000 }) + }) + + it('keeps the global limits for a plan that has none of its own', async () => { + subscriptionFindByPubkeyStub.resolves(subscription({ planId: 'basic' })) + + await (handler as any).isRateLimited(subscriber) + + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 1 }) + }) + + it('lets a plan with an empty list of limits send without limit', async () => { + settings.payments.subscriptionPlans[0].limits.rateLimits = [] + + await expect((handler as any).isRateLimited(subscriber)).to.eventually.deep.equal({ status: 'allowed' }) + + expect(rateLimiterHitStub).not.to.have.been.called + }) + + it('replaces the whole global list, including limits for kinds the plan does not mention', async () => { + eventLimits.rateLimits = [ + { period: 60000, rate: 1, kinds: [1] }, + { period: 3600000, rate: 5 }, + ] + + await (handler as any).isRateLimited(subscriber) + + expect(rateLimiterHitStub).to.have.been.calledOnce + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 100 }) + }) + + it('applies the plan limits for the kinds the plan limits name, as the global ones do', async () => { + settings.payments.subscriptionPlans[0].limits.rateLimits = [{ period: 60000, rate: 100, kinds: [7] }] + + await (handler as any).isRateLimited(subscriber) + + expect(rateLimiterHitStub).not.to.have.been.called + }) + + it('falls back to the global limits once the subscription expired', async () => { + subscriptionFindByPubkeyStub.resolves(subscription({ status: SubscriptionStatus.EXPIRED })) + + await (handler as any).isRateLimited(subscriber) + + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 1 }) + }) + + it('falls back to the global limits when the period and grace have both passed, before the sweep ran', async () => { + subscriptionFindByPubkeyStub.resolves( + subscription({ + currentPeriodEnd: new Date(Date.now() - 5 * DAY), + graceEndsAt: new Date(Date.now() - DAY), + }), + ) + + await (handler as any).isRateLimited(subscriber) + + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 1 }) + }) + + it('keeps the plan limits through the grace period', async () => { + subscriptionFindByPubkeyStub.resolves( + subscription({ + status: SubscriptionStatus.PAST_DUE, + currentPeriodEnd: new Date(Date.now() - DAY), + graceEndsAt: new Date(Date.now() + 2 * DAY), + }), + ) + + await (handler as any).isRateLimited(subscriber) + + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 100 }) + }) + + it('falls back to the global limits when the plan was removed from the settings', async () => { + subscriptionFindByPubkeyStub.resolves(subscription({ planId: 'retired' })) + + await (handler as any).isRateLimited(subscriber) + + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 1 }) + }) + + it('still lets a whitelisted pubkey send without limit', async () => { + ;(eventLimits as any).whitelists = { pubkeys: [PUBKEY] } + + await expect((handler as any).isRateLimited(subscriber)).to.eventually.deep.equal({ status: 'allowed' }) + + expect(rateLimiterHitStub).not.to.have.been.called + }) + + describe('when subscriptions are not in use', () => { + it('does not look at subscriptions while payments are off', async () => { + settings.payments.enabled = false + + await (handler as any).isRateLimited(subscriber) + + expect(subscriptionFindByPubkeyStub).not.to.have.been.called + expect(cacheStub.getKey).not.to.have.been.called + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 1 }) + }) + + it('does not look at subscriptions when no plan is configured', async () => { + settings.payments.subscriptionPlans = [] + + await (handler as any).isRateLimited(subscriber) + + expect(subscriptionFindByPubkeyStub).not.to.have.been.called + expect(cacheStub.getKey).not.to.have.been.called + }) + + it('does not look at subscriptions when no plan has limits of its own', async () => { + delete settings.payments.subscriptionPlans[0].limits + + await (handler as any).isRateLimited(subscriber) + + expect(subscriptionFindByPubkeyStub).not.to.have.been.called + expect(cacheStub.getKey).not.to.have.been.called + }) + }) + + describe('caching', () => { + it('reads the plan from the database once and caches it, no longer than the usual 300s', async () => { + await (handler as any).isRateLimited(subscriber) + + expect(subscriptionFindByPubkeyStub).to.have.been.calledOnceWithExactly(PUBKEY) + expect(cacheStub.setKey).to.have.been.calledOnceWithExactly(`${PUBKEY}:subscription-plan`, 'plan:premium', 300) + }) + + it('uses the cached plan without reading the database', async () => { + cacheStub.getKey.withArgs(`${PUBKEY}:subscription-plan`).resolves('plan:premium') + + await (handler as any).isRateLimited(subscriber) + + expect(subscriptionFindByPubkeyStub).not.to.have.been.called + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 100 }) + }) + + it('uses a cached "no plan" without reading the database', async () => { + cacheStub.getKey.withArgs(`${PUBKEY}:subscription-plan`).resolves('none') + + await (handler as any).isRateLimited(subscriber) + + expect(subscriptionFindByPubkeyStub).not.to.have.been.called + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 1 }) + }) + + it('keeps a plan whose id is "none" apart from "no plan"', async () => { + settings.payments.subscriptionPlans[0].id = 'none' + cacheStub.getKey.withArgs(`${PUBKEY}:subscription-plan`).resolves('plan:none') + + await (handler as any).isRateLimited(subscriber) + + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 100 }) + }) + + it('caches "no plan" for 60s', async () => { + subscriptionFindByPubkeyStub.resolves(undefined) + + await (handler as any).isRateLimited(subscriber) + + expect(cacheStub.setKey).to.have.been.calledOnceWithExactly(`${PUBKEY}:subscription-plan`, 'none', 60) + }) + + it('caches "no plan" for a full 60s after the subscription expired, not just until its past end', async () => { + subscriptionFindByPubkeyStub.resolves( + subscription({ status: SubscriptionStatus.EXPIRED, currentPeriodEnd: new Date(Date.now() - 5 * DAY) }), + ) + + await (handler as any).isRateLimited(subscriber) + + expect(cacheStub.setKey).to.have.been.calledOnceWithExactly(`${PUBKEY}:subscription-plan`, 'none', 60) + }) + + it('reads the subscription once for a message that is rate limited and then checked for admission', async () => { + sandbox.stub(EventMessageHandler.prototype, 'getRelayPublicKey' as any).returns('a'.repeat(64)) + userRepository.findByPubkey = sandbox.stub().resolves({ isAdmitted: true, balance: 0n }) + + await (handler as any).isRateLimited(subscriber) + await expect((handler as any).isUserAdmitted(subscriber)).to.eventually.be.undefined + + expect(subscriptionFindByPubkeyStub).to.have.been.calledOnce + }) + + it('reads the subscription again for the next message of the same sender', async () => { + sandbox.stub(EventMessageHandler.prototype, 'getRelayPublicKey' as any).returns('a'.repeat(64)) + userRepository.findByPubkey = sandbox.stub().resolves({ isAdmitted: true, balance: 0n }) + + await (handler as any).isRateLimited(subscriber) + await (handler as any).isRateLimited({ ...subscriber, id: 'e'.repeat(64) }) + + expect(subscriptionFindByPubkeyStub).to.have.been.calledTwice + }) + + it('caches the plan no longer than access lasts', async () => { + subscriptionFindByPubkeyStub.resolves( + subscription({ currentPeriodEnd: new Date(Date.now() + 20_000), graceEndsAt: new Date(Date.now() + 40_000) }), + ) + + await (handler as any).isRateLimited(subscriber) + + const [, , ttl] = cacheStub.setKey.firstCall.args + expect(ttl).to.be.within(39, 41) + }) + + it('reads the database when the cache cannot be read', async () => { + cacheStub.getKey.rejects(new Error('cache unavailable')) + + await (handler as any).isRateLimited(subscriber) + + expect(subscriptionFindByPubkeyStub).to.have.been.calledOnce + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 100 }) + }) + + it('falls back to the global limits when the subscription cannot be read, and caches nothing', async () => { + subscriptionFindByPubkeyStub.rejects(new Error('db unavailable')) + + await expect((handler as any).isRateLimited(subscriber)).to.eventually.deep.equal({ status: 'allowed' }) + + expect(rateLimiterHitStub.firstCall.args[2]).to.deep.equal({ period: 60000, rate: 1 }) + expect(cacheStub.setKey).not.to.have.been.called + }) + }) + }) }) describe('isUserAdmitted', () => { @@ -1416,7 +1720,7 @@ describe('EventMessageHandler', () => { () => settings, {} as any, cacheStub, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) }) @@ -1645,6 +1949,178 @@ describe('EventMessageHandler', () => { ) }) }) + + describe('subscriptions', () => { + const DAY = 86_400_000 + let subscriptionFindByPubkeyStub: SinonStub + + const subscription = (overrides: Partial = {}): UserSubscription => ({ + pubkey: 'f'.repeat(64), + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: new Date(Date.now() - 10 * DAY), + currentPeriodEnd: new Date(Date.now() + 20 * DAY), + graceEndsAt: null, + cancelAtPeriodEnd: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) + + beforeEach(() => { + settings.payments.subscriptionPlans = [ + { id: 'basic', amount: 1000n, intervalSeconds: 2_592_000, graceSeconds: 259_200 }, + ] + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, balance: 0n }) + subscriptionFindByPubkeyStub = sandbox.stub() + handler = new EventMessageHandler( + webSocket, + () => null, + {} as any, + userRepository, + () => settings, + {} as any, + cacheStub, + () => ({ hit: async () => ({ limited: false }) }), + {} as any, + { findByPubkey: subscriptionFindByPubkeyStub } as any, + ) + }) + + it('admits a user with an active subscription', async () => { + subscriptionFindByPubkeyStub.resolves(subscription()) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('admits an admitted user who has no subscription row, so enabling plans locks nobody out', async () => { + subscriptionFindByPubkeyStub.resolves(undefined) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('admits a user in the grace period', async () => { + subscriptionFindByPubkeyStub.resolves( + subscription({ + status: SubscriptionStatus.PAST_DUE, + currentPeriodEnd: new Date(Date.now() - DAY), + graceEndsAt: new Date(Date.now() + DAY), + }), + ) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('blocks an expired subscription and caches the block for 60s', async () => { + subscriptionFindByPubkeyStub.resolves(subscription({ status: SubscriptionStatus.EXPIRED })) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: subscription expired') + + expect(cacheStub.setKey).to.have.been.calledWith( + `${event.pubkey}:is-admitted`, + CacheAdmissionState.BLOCKED_NOT_ADMITTED, + 60, + ) + }) + + it('admits a subscription that lapsed but is still within its grace, before the sweep has moved it on', async () => { + subscriptionFindByPubkeyStub.resolves( + subscription({ + currentPeriodEnd: new Date(Date.now() - 1000), + graceEndsAt: new Date(Date.now() + 2 * DAY), + }), + ) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('blocks a lapsed subscription before the sweep has moved it on', async () => { + subscriptionFindByPubkeyStub.resolves( + subscription({ currentPeriodEnd: new Date(Date.now() - 1000), graceEndsAt: null }), + ) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: subscription expired') + }) + + it('blocks a user who was never admitted, without looking at subscriptions', async () => { + userRepositoryFindByPubkeyStub.resolves(undefined) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: pubkey not admitted') + + expect(subscriptionFindByPubkeyStub).not.to.have.been.called + }) + + it('caches admission no longer than the access lasts', async () => { + subscriptionFindByPubkeyStub.resolves(subscription({ currentPeriodEnd: new Date(Date.now() + 40_000) })) + + await (handler as any).isUserAdmitted(event) + + const [, , ttl] = cacheStub.setKey.lastCall.args + expect(ttl).to.be.within(39, 41) + }) + + it('caches the plan with the admission, so the rate limits need no second read', async () => { + subscriptionFindByPubkeyStub.resolves(subscription()) + + await (handler as any).isUserAdmitted(event) + + expect(cacheStub.setKey).to.have.been.calledWith(`${event.pubkey}:subscription-plan`, 'plan:basic', 300) + }) + + it('caches that a user admitted without a subscription has no plan', async () => { + subscriptionFindByPubkeyStub.resolves(undefined) + + await (handler as any).isUserAdmitted(event) + + expect(cacheStub.setKey).to.have.been.calledWith(`${event.pubkey}:subscription-plan`, 'none', 60) + }) + + it('caches admission for the usual 300s when access lasts longer', async () => { + subscriptionFindByPubkeyStub.resolves(subscription()) + + await (handler as any).isUserAdmitted(event) + + expect(cacheStub.setKey).to.have.been.calledWith(`${event.pubkey}:is-admitted`, CacheAdmissionState.ADMITTED, 300) + }) + + it('uses the cache without reading subscriptions', async () => { + cacheStub.getKey.resolves(CacheAdmissionState.ADMITTED) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + + expect(subscriptionFindByPubkeyStub).not.to.have.been.called + }) + + it('gates on the plan when no admission fee is configured', async () => { + settings.payments.feeSchedules.admission = [] + subscriptionFindByPubkeyStub.resolves(subscription({ status: SubscriptionStatus.EXPIRED })) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: subscription expired') + }) + + it('lets a pubkey whitelisted on the only admission fee through without a subscription', async () => { + settings.payments.feeSchedules.admission[0].whitelists.pubkeys = [event.pubkey] + subscriptionFindByPubkeyStub.resolves(subscription({ status: SubscriptionStatus.EXPIRED })) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('ignores subscriptions while payments are off', async () => { + settings.payments.enabled = false + subscriptionFindByPubkeyStub.resolves(subscription({ status: SubscriptionStatus.EXPIRED })) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('ignores subscriptions when no plan is configured', async () => { + settings.payments.subscriptionPlans = [] + subscriptionFindByPubkeyStub.resolves(subscription({ status: SubscriptionStatus.EXPIRED })) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + + expect(subscriptionFindByPubkeyStub).not.to.have.been.called + }) + }) }) describe('checkNip05Verification', () => { @@ -1690,7 +2166,7 @@ describe('EventMessageHandler', () => { () => settings, nip05VerificationRepository, { hasKey: async () => false, setKey: async () => true, getKey: async () => null } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) }) @@ -1846,7 +2322,7 @@ describe('EventMessageHandler', () => { }) as any, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) }) @@ -1897,7 +2373,7 @@ describe('EventMessageHandler', () => { () => settings, nip05VerificationRepository, { hasKey: async () => false, setKey: async () => true, getKey: async () => null } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) }) @@ -2141,7 +2617,7 @@ describe('EventMessageHandler', () => { () => settings, nip05VerificationRepository, { hasKey: async () => false, setKey: async () => true, getKey: async () => null } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) }) @@ -2318,7 +2794,7 @@ describe('EventMessageHandler', () => { () => settings, nip05VerificationRepository, { hasKey: async () => false, setKey: async () => true, getKey: async () => null } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) }) @@ -2517,7 +2993,7 @@ describe('EventMessageHandler', () => { () => ({ info: { relay_url: 'relay_url' }, nip42: { authRequired: false } }) as any, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) @@ -2533,7 +3009,7 @@ describe('EventMessageHandler', () => { () => ({ info: { relay_url: 'relay_url' }, nip42: { authRequired: true } }) as any, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) @@ -2551,7 +3027,7 @@ describe('EventMessageHandler', () => { () => ({ info: { relay_url: 'relay_url' }, nip42: { authRequired: true } }) as any, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) @@ -2574,7 +3050,7 @@ describe('EventMessageHandler', () => { }) as any, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) }) @@ -2607,7 +3083,7 @@ describe('EventMessageHandler', () => { () => ({ info: { relay_url: 'relay_url' } }) as any, {} as any, { hasKey: async () => false, setKey: async () => true } as any, - () => ({ hit: async () => false }), + () => ({ hit: async () => ({ limited: false }) }), {} as any, ) expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined diff --git a/test/unit/handlers/neg-close-message-handler.spec.ts b/test/unit/handlers/neg-close-message-handler.spec.ts new file mode 100644 index 000000000..b483fc20d --- /dev/null +++ b/test/unit/handlers/neg-close-message-handler.spec.ts @@ -0,0 +1,60 @@ +import { expect } from 'chai' +import EventEmitter from 'events' + +import { IWebSocketAdapter } from '../../../src/@types/adapters' +import { MessageType } from '../../../src/@types/messages' +import { NegCloseMessageHandler } from '../../../src/handlers/neg-close-message-handler' +import { Negentropy, NegentropyStorageVector } from '../../../src/utils/negentropy' +import { NegentropySessions } from '../../../src/utils/negentropy-sessions' +import { WebSocketAdapterEvent } from '../../../src/constants/adapter' + +describe('NegCloseMessageHandler', () => { + let sessions: NegentropySessions + let webSocket: IWebSocketAdapter + let handler: NegCloseMessageHandler + let emitted: unknown[][] + + const openSession = (id: string) => { + const storage = new NegentropyStorageVector() + storage.seal() + sessions.activate(id, sessions.reserve(id, 3) as symbol, { negentropy: new Negentropy(storage), filter: {} }, 60000) + } + + beforeEach(() => { + sessions = new NegentropySessions(() => undefined) + webSocket = new EventEmitter() as any + webSocket.getNegentropySessions = () => sessions + emitted = [] + webSocket.on(WebSocketAdapterEvent.Message, (...args) => emitted.push(args)) + handler = new NegCloseMessageHandler(webSocket) + }) + + afterEach(() => { + sessions.closeAll() + webSocket.removeAllListeners() + }) + + it('closes the session without replying', async () => { + openSession('sync1') + + await handler.handleMessage([MessageType.NEG_CLOSE, 'sync1'] as any) + + expect(sessions.getSessionIds()).to.deep.equal([]) + expect(emitted).to.deep.equal([]) + }) + + it('only closes the named session', async () => { + openSession('a') + openSession('b') + + await handler.handleMessage([MessageType.NEG_CLOSE, 'a'] as any) + + expect(sessions.getSessionIds()).to.deep.equal(['b']) + }) + + it('ignores an unknown id', async () => { + await handler.handleMessage([MessageType.NEG_CLOSE, 'nope'] as any) + + expect(emitted).to.deep.equal([]) + }) +}) diff --git a/test/unit/handlers/neg-msg-message-handler.spec.ts b/test/unit/handlers/neg-msg-message-handler.spec.ts new file mode 100644 index 000000000..925fe21da --- /dev/null +++ b/test/unit/handlers/neg-msg-message-handler.spec.ts @@ -0,0 +1,192 @@ +import chai from 'chai' +import { createHash } from 'crypto' +import EventEmitter from 'events' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { IWebSocketAdapter } from '../../../src/@types/adapters' +import { MessageType } from '../../../src/@types/messages' +import { Settings } from '../../../src/@types/settings' +import { WebSocketAdapterEvent } from '../../../src/constants/adapter' +import { NegMsgMessageHandler } from '../../../src/handlers/neg-msg-message-handler' +import { Negentropy, NegentropyStorageVector } from '../../../src/utils/negentropy' +import { NegentropySessions } from '../../../src/utils/negentropy-sessions' + +chai.use(sinonChai) +const { expect } = chai + +const storageOf = (count: number): NegentropyStorageVector => { + const storage = new NegentropyStorageVector() + for (let i = 0; i < count; i++) { + storage.insert(1700000000 + i, createHash('sha256').update(`neg-msg-${i}`).digest()) + } + storage.seal() + + return storage +} + +describe('NegMsgMessageHandler', () => { + let sandbox: Sinon.SinonSandbox + let webSocket: IWebSocketAdapter + let sessions: NegentropySessions + let settings: Settings + let emitted: Sinon.SinonStub + let handler: NegMsgMessageHandler + + const send = (id: string, hex: string) => handler.handleMessage([MessageType.NEG_MSG, id, hex] as any) + + const openSession = (id: string, itemCount = 5, filter: any = { kinds: [1] }) => { + const token = sessions.reserve(id, 3) as symbol + sessions.activate(id, token, { negentropy: new Negentropy(storageOf(itemCount)), filter }, 60000) + } + + const validMessageHex = Buffer.from(new Negentropy(storageOf(0)).initiate()).toString('hex') + + beforeEach(() => { + sandbox = Sinon.createSandbox() + sessions = new NegentropySessions(() => undefined) + settings = { nip77: { enabled: true } } as Settings + + webSocket = new EventEmitter() as any + webSocket.getNegentropySessions = () => sessions + + emitted = sandbox.stub() + webSocket.on(WebSocketAdapterEvent.Message, emitted) + + handler = new NegMsgMessageHandler(webSocket, () => settings) + }) + + afterEach(() => { + sessions.closeAll() + webSocket.removeAllListeners() + sandbox.restore() + }) + + it('answers a message on an open session with a NEG-MSG', async () => { + openSession('sync1') + + await send('sync1', validMessageHex) + + expect(emitted).to.have.been.calledOnce + const [type, id, payload] = emitted.firstCall.args[0] + expect(type).to.equal(MessageType.NEG_MSG) + expect(id).to.equal('sync1') + expect(payload).to.match(/^61(?:[0-9a-f]{2})+$/) + expect(sessions.getSessionIds()).to.deep.equal(['sync1']) + }) + + it('replies NEG-ERR closed for an unknown subscription id', async () => { + await send('nope', validMessageHex) + + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'nope', + 'closed: unknown negentropy subscription', + ]) + }) + + it('does not answer for a REQ subscription id: the namespaces are separate', async () => { + // A REQ subscription named "sync1" lives in the adapter's subscriptions, not in the negentropy sessions. + await send('sync1', validMessageHex) + + expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_ERR) + }) + + it('closes the session and replies NEG-ERR when the message is malformed', async () => { + openSession('sync1') + + await send('sync1', '6100') + + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'sync1', + 'invalid: malformed negentropy message', + ]) + expect(sessions.getSessionIds()).to.deep.equal([]) + }) + + it('keeps other sessions when one is closed by an error', async () => { + openSession('a') + openSession('b') + + await send('a', '6100') + + expect(sessions.getSessionIds()).to.deep.equal(['b']) + }) + + it('ends the session with NEG-ERR blocked when NIP-77 is turned off while it is open', async () => { + openSession('sync1') + settings = { nip77: { enabled: false } } as Settings + + await send('sync1', validMessageHex) + + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'sync1', + 'blocked: NIP-77 is disabled by relay configuration', + ]) + expect(sessions.getSessionIds()).to.deep.equal([]) + }) + + describe('authorization is checked on every message', () => { + const pubkey = 'f'.repeat(64) + let authenticated: Set + + beforeEach(() => { + authenticated = new Set([pubkey]) + webSocket.getAuthenticatedPubkeys = () => authenticated + settings = { nip77: { enabled: true }, nip42: { restrictedReads: { enabled: true } } } as Settings + }) + + it('keeps answering while the client is still authorized', async () => { + openSession('sync1', 5, { authors: [pubkey] }) + + await send('sync1', validMessageHex) + + expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_MSG) + }) + + it('closes the session with auth-required once authentication lapses', async () => { + openSession('sync1', 5, { authors: [pubkey] }) + authenticated.clear() // e.g. nip42.sessionExpirySeconds elapsed + + await send('sync1', validMessageHex) + + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'sync1', + 'auth-required: authentication is required to sync these event kinds', + ]) + expect(sessions.getSessionIds()).to.deep.equal([]) + }) + + it('closes the session when restricted reads were switched on after it opened', async () => { + settings = { nip77: { enabled: true } } as Settings + openSession('sync1', 5, {}) + settings = { nip77: { enabled: true }, nip42: { restrictedReads: { enabled: true } } } as Settings + authenticated.clear() + + await send('sync1', validMessageHex) + + expect(emitted.firstCall.args[0][2]).to.match(/^auth-required:/) + expect(sessions.getSessionIds()).to.deep.equal([]) + }) + + it('does not ask for auth when the session only covers ordinary kinds', async () => { + openSession('sync1', 5, { kinds: [1] }) + authenticated.clear() + + await send('sync1', validMessageHex) + + expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_MSG) + }) + }) + + it('answers an unsupported protocol version with just the supported version', async () => { + openSession('sync1') + + await send('sync1', '62') + + expect(emitted).to.have.been.calledOnceWithExactly([MessageType.NEG_MSG, 'sync1', '61']) + }) +}) diff --git a/test/unit/handlers/neg-open-message-handler.spec.ts b/test/unit/handlers/neg-open-message-handler.spec.ts new file mode 100644 index 000000000..6212ce5bd --- /dev/null +++ b/test/unit/handlers/neg-open-message-handler.spec.ts @@ -0,0 +1,576 @@ +import chai from 'chai' +import { createHash } from 'crypto' +import EventEmitter from 'events' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { IWebSocketAdapter } from '../../../src/@types/adapters' +import { MessageType } from '../../../src/@types/messages' +import { IEventRepository } from '../../../src/@types/repositories' +import { Settings } from '../../../src/@types/settings' +import { WebSocketAdapterEvent } from '../../../src/constants/adapter' +import { NegMsgMessageHandler } from '../../../src/handlers/neg-msg-message-handler' +import { NegOpenMessageHandler } from '../../../src/handlers/neg-open-message-handler' +import { Negentropy, NegentropyStorageVector } from '../../../src/utils/negentropy' +import { NegentropySessions } from '../../../src/utils/negentropy-sessions' + +chai.use(sinonChai) +const { expect } = chai + +const BASE_TIMESTAMP = 1700000000 + +const idFor = (n: number): Buffer => createHash('sha256').update(`neg-open-${n}`).digest() + +const rowsFor = (from: number, to: number) => { + const rows: { event_created_at: number; event_id: Buffer }[] = [] + for (let i = from; i < to; i++) { + rows.push({ event_created_at: BASE_TIMESTAMP + i, event_id: idFor(i) }) + } + + return rows +} + +const clientFor = (from: number, to: number): Negentropy => { + const storage = new NegentropyStorageVector() + for (const row of rowsFor(from, to)) { + storage.insert(row.event_created_at, row.event_id) + } + storage.seal() + + return new Negentropy(storage) +} + +const emptyClientMessageHex = Buffer.from(clientFor(0, 0).initiate()).toString('hex') + +describe('NegOpenMessageHandler', () => { + let sandbox: Sinon.SinonSandbox + let webSocket: IWebSocketAdapter + let sessions: NegentropySessions + let eventRepository: IEventRepository + let findReconciliationItems: Sinon.SinonStub + let settings: Settings + let emitted: Sinon.SinonStub + let handler: NegOpenMessageHandler + + const open = (id = 'sync1', filter: any = { kinds: [1] }, hex = emptyClientMessageHex) => + handler.handleMessage([MessageType.NEG_OPEN, id, filter, hex] as any) + + beforeEach(() => { + sandbox = Sinon.createSandbox() + sessions = new NegentropySessions(() => undefined) + findReconciliationItems = sandbox.stub().resolves(rowsFor(0, 5)) + eventRepository = { findReconciliationItems } as any + settings = { nip77: { enabled: true, maxEventsPerSession: 100, maxSessionsPerConnection: 2 } } as Settings + + webSocket = new EventEmitter() as any + webSocket.getNegentropySessions = () => sessions + webSocket.getAuthenticatedPubkeys = () => new Set() + + emitted = sandbox.stub() + webSocket.on(WebSocketAdapterEvent.Message, emitted) + + handler = new NegOpenMessageHandler(webSocket, eventRepository, () => settings) + }) + + afterEach(() => { + sessions.closeAll() + webSocket.removeAllListeners() + sandbox.restore() + }) + + it('answers the initial message with a NEG-MSG and keeps the session open', async () => { + await open() + + expect(emitted).to.have.been.calledOnce + const [type, id, payload] = emitted.firstCall.args[0] + expect(type).to.equal(MessageType.NEG_MSG) + expect(id).to.equal('sync1') + expect(payload).to.match(/^61(?:[0-9a-f]{2})+$/) + expect(sessions.getSessionIds()).to.deep.equal(['sync1']) + }) + + it('reads the matching events with one row of headroom over the limit', async () => { + await open('sync1', { kinds: [1], limit: 3 }) + + expect(findReconciliationItems).to.have.been.calledOnceWithExactly({ kinds: [1], limit: 3 }, 101) + }) + + describe('when disabled', () => { + it('replies NEG-ERR blocked and does not read events', async () => { + settings = { nip77: { enabled: false } } as Settings + + await open() + + expect(findReconciliationItems).to.not.have.been.called + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'sync1', + 'blocked: NIP-77 is disabled by relay configuration', + ]) + expect(sessions.getSessionIds()).to.deep.equal([]) + }) + + it('is disabled when the nip77 section is missing', async () => { + settings = {} as Settings + + await open() + + expect(emitted.firstCall.args[0][2]).to.match(/^blocked: NIP-77 is disabled/) + }) + }) + + describe('limits', () => { + it('rejects a query that matches more events than allowed, with the limit as 4th element', async () => { + findReconciliationItems.resolves(rowsFor(0, 101)) + + await open() + + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'sync1', + 'blocked: this query is too big', + 100, + ]) + expect(sessions.getSessionIds()).to.deep.equal([]) + }) + + it('accepts a query that matches exactly the limit', async () => { + findReconciliationItems.resolves(rowsFor(0, 100)) + + await open() + + expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_MSG) + }) + + it('frees the slot of a rejected oversized query', async () => { + findReconciliationItems.resolves(rowsFor(0, 101)) + await open('big') + + findReconciliationItems.resolves(rowsFor(0, 5)) + await open('a') + await open('b') + + expect(sessions.getSessionIds()).to.have.members(['a', 'b']) + }) + + it('rejects a new session beyond the per-connection limit', async () => { + await open('a') + await open('b') + emitted.resetHistory() + + await open('c') + + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'c', + 'blocked: too many negentropy sessions or reads in progress on this connection (max 2)', + ]) + expect(sessions.getSessionIds()).to.have.members(['a', 'b']) + }) + + it('lets a client reopen an id it already has when at the limit, replacing the session', async () => { + await open('a') + await open('b') + emitted.resetHistory() + + await open('a') + + expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_MSG) + expect(sessions.getSessionIds()).to.have.members(['a', 'b']) + }) + + it('uses the defaults when no limits are configured', async () => { + settings = { nip77: { enabled: true } } as Settings + + await open() + + expect(findReconciliationItems.firstCall.args[1]).to.equal(100001) + }) + + it('rejects subscription ids over the configured length', async () => { + settings = { + nip77: { enabled: true }, + limits: { client: { subscription: { maxSubscriptionIdLength: 4 } } }, + } as Settings + + await open('too-long') + + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'too-long', + 'invalid: subscription id too long (max 4)', + ]) + expect(findReconciliationItems).to.not.have.been.called + }) + + it('rejects filters with too many values', async () => { + settings = { + nip77: { enabled: true }, + limits: { client: { subscription: { maxFilterValues: 2 } } }, + } as Settings + + await open('sync1', { authors: ['a', 'b', 'c'] }) + + expect(emitted.firstCall.args[0][2]).to.equal('blocked: too many filter values (max 2)') + expect(findReconciliationItems).to.not.have.been.called + }) + }) + + describe('NIP-42', () => { + const pubkey = 'f'.repeat(64) + const otherPubkey = 'e'.repeat(64) + + beforeEach(() => { + settings = { nip77: { enabled: true }, nip42: { restrictedReads: { enabled: true } } } as Settings + }) + + const expectAuthRequired = () => { + expect(findReconciliationItems).to.not.have.been.called + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'sync1', + 'auth-required: authentication is required to sync these event kinds', + ]) + } + + it('requires auth to sync restricted kinds', async () => { + await open('sync1', { kinds: [1059], '#p': [pubkey] }) + + expectAuthRequired() + }) + + // REQ hides restricted events one by one; reconciliation would list their ids, so a filter that + // could match one has to be scoped to the client. + it('requires auth for a filter that names no kinds, which could match restricted events', async () => { + await open('sync1', {}) + + expectAuthRequired() + }) + + it('requires auth for an authors-only filter of someone else', async () => { + webSocket.getAuthenticatedPubkeys = () => new Set([pubkey]) + + await open('sync1', { authors: [otherPubkey] }) + + expectAuthRequired() + }) + + it('requires auth for a filter mixing a restricted and an ordinary kind', async () => { + await open('sync1', { kinds: [1, 1059] }) + + expectAuthRequired() + }) + + it('requires every author to be authenticated', async () => { + webSocket.getAuthenticatedPubkeys = () => new Set([pubkey]) + + await open('sync1', { authors: [pubkey, otherPubkey] }) + + expectAuthRequired() + }) + + it('allows restricted kinds scoped to the authenticated pubkey', async () => { + webSocket.getAuthenticatedPubkeys = () => new Set([pubkey]) + + await open('sync1', { kinds: [1059], '#p': [pubkey] }) + + expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_MSG) + }) + + it('allows a filter without kinds when it is scoped to the authenticated pubkey', async () => { + webSocket.getAuthenticatedPubkeys = () => new Set([pubkey]) + + await open('sync1', { authors: [pubkey] }) + + expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_MSG) + }) + + it('does not restrict filters whose kinds are all ordinary', async () => { + await open('sync1', { kinds: [1, 7] }) + + expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_MSG) + }) + + it('does not restrict anything when restricted reads are off', async () => { + settings = { nip77: { enabled: true } } as Settings + + await open('sync1', {}) + + expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_MSG) + }) + }) + + describe('malformed input', () => { + it('rejects an initial message that is not a negentropy message, before reading events', async () => { + await open('sync1', {}, 'ff00') + + expect(findReconciliationItems).to.not.have.been.called + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'sync1', + 'invalid: malformed negentropy message', + ]) + }) + + it('rejects a truncated initial message and keeps no session', async () => { + await open('sync1', {}, '6100') + + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'sync1', + 'invalid: malformed negentropy message', + ]) + expect(sessions.getSessionIds()).to.deep.equal([]) + }) + + it('answers an unsupported protocol version with just the supported version', async () => { + await open('sync1', {}, '62') + + expect(emitted).to.have.been.calledOnceWithExactly([MessageType.NEG_MSG, 'sync1', '61']) + }) + }) + + describe('failures', () => { + it('replies with a generic NEG-ERR when reading events fails, without leaking the cause', async () => { + findReconciliationItems.rejects(new Error('connection to 10.0.0.5 refused')) + + await open() + + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'sync1', + 'error: unable to process negentropy request', + ]) + expect(sessions.getSessionIds()).to.deep.equal([]) + }) + + it('frees the slot after a failure', async () => { + findReconciliationItems.rejects(new Error('boom')) + await open('a') + await open('b') + findReconciliationItems.resolves(rowsFor(0, 5)) + + await open('c') + await open('d') + + expect(sessions.getSessionIds()).to.have.members(['c', 'd']) + }) + + it('stays silent when the client closed the session while events were being read', async () => { + let finishRead: (rows: any[]) => void = () => undefined + findReconciliationItems.returns(new Promise((resolve) => (finishRead = resolve))) + + const pending = open('sync1') + sessions.close('sync1') + finishRead(rowsFor(0, 5)) + await pending + + expect(emitted).to.not.have.been.called + expect(sessions.getSessionIds()).to.deep.equal([]) + }) + + it('lets a newer NEG-OPEN win when two race on the same id', async () => { + const finishers: ((rows: any[]) => void)[] = [] + findReconciliationItems.callsFake(() => new Promise((resolve) => finishers.push(resolve))) + + const first = open('sync1', { kinds: [1] }) + const second = open('sync1', { kinds: [7] }) + finishers[1](rowsFor(0, 5)) + await second + finishers[0](rowsFor(0, 5)) + await first + + expect(emitted).to.have.been.calledOnce + expect(sessions.getSessionIds()).to.deep.equal(['sync1']) + }) + }) + + describe('replacing an open session', () => { + const pendingReads = () => { + const finishers: { resolve: (rows: any[]) => void; reject: (error: Error) => void }[] = [] + findReconciliationItems.callsFake(() => new Promise((resolve, reject) => finishers.push({ resolve, reject }))) + + return finishers + } + + // NIP-77: after a NEG-ERR the subscription is considered closed. + for (const [name, arrange, hex, filter] of [ + ['a bad version byte', () => undefined, 'ff00', {}], + [ + 'too many filter values', + () => + (settings = { + nip77: { enabled: true }, + limits: { client: { subscription: { maxFilterValues: 1 } } }, + } as Settings), + emptyClientMessageHex, + { authors: ['a', 'b'] }, + ], + [ + 'NIP-77 being turned off', + () => (settings = { nip77: { enabled: false } } as Settings), + emptyClientMessageHex, + {}, + ], + [ + 'an unauthorized filter', + () => (settings = { nip77: { enabled: true }, nip42: { restrictedReads: { enabled: true } } } as Settings), + emptyClientMessageHex, + {}, + ], + ] as [string, () => void, string, any][]) { + it(`closes the session the client already had when the replacement is rejected for ${name}`, async () => { + await open('sync1') + expect(sessions.getSessionIds()).to.deep.equal(['sync1']) + arrange() + emitted.resetHistory() + + await open('sync1', filter, hex) + + expect(emitted).to.have.been.calledOnce + expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_ERR) + expect(sessions.getSessionIds()).to.deep.equal([]) + }) + } + + it('frees the slot of a session whose replacement was rejected', async () => { + await open('a') + await open('b') + + await open('a', {}, 'ff00') + + await open('c') + expect(sessions.getSessionIds()).to.have.members(['b', 'c']) + }) + + it('stays silent when a replaced request turns out to be too big', async () => { + const reads = pendingReads() + const first = open('sync1', { kinds: [1] }) + const second = open('sync1', { kinds: [7] }) + reads[1].resolve(rowsFor(0, 5)) + await second + emitted.resetHistory() + + reads[0].resolve(rowsFor(0, 101)) + await first + + expect(emitted).to.not.have.been.called + expect(sessions.getSessionIds()).to.deep.equal(['sync1']) + }) + + it('stays silent when a replaced request fails to read', async () => { + const reads = pendingReads() + const first = open('sync1', { kinds: [1] }) + const second = open('sync1', { kinds: [7] }) + reads[1].resolve(rowsFor(0, 5)) + await second + emitted.resetHistory() + + reads[0].reject(new Error('boom')) + await first + + expect(emitted).to.not.have.been.called + expect(sessions.getSessionIds()).to.deep.equal(['sync1']) + }) + + it('stays silent when a replaced request had a malformed message', async () => { + const reads = pendingReads() + const first = open('sync1', { kinds: [1] }, '6100') + const second = open('sync1', { kinds: [7] }) + reads[1].resolve(rowsFor(0, 5)) + await second + emitted.resetHistory() + + reads[0].resolve(rowsFor(0, 5)) + await first + + expect(emitted).to.not.have.been.called + expect(sessions.getSessionIds()).to.deep.equal(['sync1']) + }) + + it('does no work for a replaced request once its read returns', async () => { + const reads = pendingReads() + const first = open('sync1', { kinds: [1] }) + const second = open('sync1', { kinds: [7] }) + reads[1].resolve(rowsFor(0, 5)) + await second + emitted.resetHistory() + const sealSpy = sandbox.spy(NegentropyStorageVector.prototype, 'seal') + + reads[0].resolve(rowsFor(0, 50)) + await first + + expect(sealSpy).to.not.have.been.called + }) + + it('does not let repeated NEG-OPENs on one id pile up event reads', async () => { + const reads = pendingReads() + + const opens = [open('sync1'), open('sync1')] + await open('sync1') // a third read while the first two have not returned + + expect(findReconciliationItems).to.have.been.calledTwice + expect(emitted).to.have.been.calledOnceWithExactly([ + MessageType.NEG_ERR, + 'sync1', + 'blocked: too many negentropy sessions or reads in progress on this connection (max 2)', + ]) + + reads.forEach((read) => read.resolve(rowsFor(0, 5))) + await Promise.all(opens) + }) + + it('accepts new reads again once the earlier ones have returned', async () => { + const reads = pendingReads() + const opens = [open('sync1'), open('sync1')] + reads.forEach((read) => read.resolve(rowsFor(0, 5))) + await Promise.all(opens) + findReconciliationItems.resolves(rowsFor(0, 5)) + emitted.resetHistory() + + await open('sync1') + + expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_MSG) + }) + }) + + describe('a full reconciliation through the handlers', () => { + const decode = (message: any[]) => new Uint8Array(Buffer.from(message[2], 'hex')) + + // The relay holds events 10-59; the client holds 0-39. The client must learn that it lacks 40-59 + // and that the relay lacks 0-9. + it('lets a client learn exactly which events differ', async () => { + findReconciliationItems.resolves(rowsFor(10, 60)) + const msgHandler = new NegMsgMessageHandler(webSocket, () => settings) + const client = clientFor(0, 40) + const haveIds = new Set() + const needIds = new Set() + + let outgoing: Uint8Array | null = client.initiate() + let rounds = 0 + while (outgoing !== null) { + expect(++rounds).to.be.lessThan(20) + emitted.resetHistory() + const hex = Buffer.from(outgoing).toString('hex') + if (rounds === 1) { + await open('sync1', { kinds: [1] }, hex) + } else { + await msgHandler.handleMessage([MessageType.NEG_MSG, 'sync1', hex] as any) + } + + expect(emitted).to.have.been.calledOnce + const reply = emitted.firstCall.args[0] + expect(reply[0]).to.equal(MessageType.NEG_MSG) + + const result = client.reconcile(decode(reply)) + result.haveIds.forEach((id) => haveIds.add(id)) + result.needIds.forEach((id) => needIds.add(id)) + outgoing = result.output + } + + const hexOf = (from: number, to: number) => rowsFor(from, to).map((row) => row.event_id.toString('hex')) + expect([...haveIds].sort()).to.deep.equal(hexOf(0, 10).sort()) + expect([...needIds].sort()).to.deep.equal(hexOf(40, 60).sort()) + }) + }) +}) diff --git a/test/unit/handlers/request-handlers/root-request-handler.spec.ts b/test/unit/handlers/request-handlers/root-request-handler.spec.ts index 838e6588e..107fb02f6 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -12,6 +12,7 @@ import { rootRequestHandler, } from '../../../../src/handlers/request-handlers/root-request-handler' import { DEFAULT_FILTER_LIMIT } from '../../../../src/constants/base' +import * as eventStoreUtils from '../../../../src/utils/event-store' import * as eventUtils from '../../../../src/utils/event' import { toBech32 } from '../../../../src/utils/transform' @@ -183,6 +184,90 @@ describe('rootRequestHandler', () => { expect(doc).to.not.have.property('terms_of_service') }) + describe('peering advertisement', () => { + const settlementFreePeering = { + enabled: true, + contact: 'mailto:peering@relay.example.com', + endpoint: 'wss://relay.example.com', + scopes: [ + { kinds: [1, 6, [10000, 19999]], windowSeconds: 3600 }, + { kinds: [3], since: 1700000000, until: 1800000000 }, + ], + } + + const sendWithPeering = (peering?: Record) => { + createSettingsStub.returns({ + ...baseSettings, + ...(peering !== undefined ? { peering } : {}), + }) + + rootRequestHandler(req, res, next) + + return res.send.firstCall.args[0] + } + + it('advertises only enabled: false when peering is not configured', () => { + expect(sendWithPeering()).to.have.property('peering').deep.equal({ enabled: false }) + }) + + it('does not expose terms, scopes or contact while peering is disabled', () => { + const doc = sendWithPeering({ ...settlementFreePeering, enabled: false }) + + expect(doc.peering).to.deep.equal({ enabled: false }) + }) + + it('advertises settlement-free scopes with no fee entry', () => { + const doc = sendWithPeering(settlementFreePeering) + + expect(doc.peering).to.deep.equal({ + enabled: true, + contact: 'mailto:peering@relay.example.com', + endpoint: 'wss://relay.example.com', + min_pow_difficulty: 0, + scopes: [ + { kinds: [1, 6, [10000, 19999]], window_seconds: 3600 }, + { kinds: [3], since: 1700000000, until: 1800000000 }, + ], + terms: [], + }) + }) + + it('advertises paid terms and a minimum request proof-of-work', () => { + const doc = sendWithPeering({ + ...settlementFreePeering, + contact: 'mailto:peering@relay.example.com', + minRequestPowDifficulty: 20, + terms: [{ amount: 21000, unit: 'msats', period: 2592000 }], + }) + + expect(doc.peering.min_pow_difficulty).to.equal(20) + expect(doc.peering.terms).to.deep.equal([{ amount: 21000, unit: 'msats', period: 2592000 }]) + }) + + it('omits an unset contact or endpoint from the advertisement', () => { + const doc = sendWithPeering({ + enabled: true, + endpoint: 'wss://relay.example.com', + scopes: [{ kinds: [1] }], + }) + + expect(doc.peering).to.not.have.property('contact') + expect(doc.peering.endpoint).to.equal('wss://relay.example.com') + }) + + it('trims addresses and omits whitespace-only ones', () => { + const doc = sendWithPeering({ + enabled: true, + contact: ' mailto:peering@relay.example.com ', + endpoint: ' ', + scopes: [{ kinds: [1] }], + }) + + expect(doc.peering.contact).to.equal('mailto:peering@relay.example.com') + expect(doc.peering).to.not.have.property('endpoint') + }) + }) + // NIP-43 clients verify relay-signed events against `self`, so the document // must advertise the key the relay actually signs with, not a stale or // placeholder string. @@ -265,6 +350,47 @@ describe('rootRequestHandler', () => { expect(doc.limitation.default_limit).to.equal(DEFAULT_FILTER_LIMIT) }) + describe('supported_nips and NIP-77', () => { + const advertised = (settings: any) => { + createSettingsStub.returns(settings) + rootRequestHandler(req, res, next) + return res.send.lastCall.args[0].supported_nips + } + + it('advertises 77 when NIP-77 is enabled on the PostgreSQL backend', () => { + expect(advertised({ ...baseSettings, nip77: { enabled: true } })).to.include(77) + expect( + advertised({ ...baseSettings, nip77: { enabled: true }, eventStore: { backend: 'postgres' } }), + ).to.include(77) + }) + + it('does not advertise 77 when NIP-77 is disabled or unset', () => { + expect(advertised(baseSettings)).to.not.include(77) + expect(advertised({ ...baseSettings, nip77: { enabled: false } })).to.not.include(77) + }) + + it('does not advertise 77 when another event store backend is used', () => { + // postgres is the only backend the settings accept today; simulate the strfry backend + // that will bring its own negentropy implementation. + const backendStub = sinon.stub(eventStoreUtils, 'getEventStoreBackend').returns('strfry' as any) + try { + expect(advertised({ ...baseSettings, nip77: { enabled: true } })).to.not.include(77) + } finally { + backendStub.restore() + } + }) + + it('still gates 43 on NIP-43 independently of 77', () => { + const both = advertised({ ...baseSettings, nip43: { enabled: true }, nip77: { enabled: true } }) + expect(both).to.include(43) + expect(both).to.include(77) + + const onlyNip77 = advertised({ ...baseSettings, nip77: { enabled: true } }) + expect(onlyNip77).to.not.include(43) + expect(onlyNip77).to.include(77) + }) + }) + it('sets limitation.search_supported to false when NIP-50 is disabled', () => { rootRequestHandler(req, res, next) @@ -367,6 +493,93 @@ describe('rootRequestHandler', () => { expect(res.send.firstCall.args[0].limitation.restricted_writes).to.equal(true) }) + describe('subscription plans', () => { + const plans = [ + { id: 'basic', amount: 1000000n, intervalSeconds: 2592000, graceSeconds: 259200 }, + { + id: 'vip', + amount: 25000000n, + intervalSeconds: 31536000, + graceSeconds: 0, + description: 'Everything, for a year', + }, + ] + const withPlans = (payments: Record = {}) => ({ + ...baseSettings, + payments: { enabled: true, processor: 'lnbits', feeSchedules: { admission: [] }, subscriptionPlans: plans, ...payments }, + }) + + it('lists the plans under fees.subscription with the price in msats and the period in seconds', () => { + createSettingsStub.returns(withPlans()) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].fees.subscription).to.deep.equal([ + { id: 'basic', amount: 1000000, unit: 'msats', period: 2592000 }, + { id: 'vip', amount: 25000000, unit: 'msats', period: 31536000, description: 'Everything, for a year' }, + ]) + }) + + it('sends a document that can be serialized, although plan prices are bigints', () => { + createSettingsStub.returns(withPlans()) + + rootRequestHandler(req, res, next) + + expect(() => JSON.stringify(res.send.firstCall.args[0])).to.not.throw() + }) + + it('keeps the admission fees next to the plans', () => { + createSettingsStub.returns(withPlans({ feeSchedules: { admission: [{ enabled: true, amount: 21000 }] } })) + + rootRequestHandler(req, res, next) + + const { fees } = res.send.firstCall.args[0] + expect(fees.admission).to.deep.equal([{ amount: 21000, unit: 'msats' }]) + expect(fees.subscription).to.have.length(2) + }) + + it('does not advertise plans when none is configured', () => { + createSettingsStub.returns(withPlans({ subscriptionPlans: [] })) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].fees).to.not.have.property('subscription') + }) + + it('does not advertise plans while payments are off', () => { + createSettingsStub.returns(withPlans({ enabled: false })) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].fees).to.not.have.property('subscription') + }) + + it('does not advertise plans when the settings have no payments block', () => { + const { payments: _payments, ...settingsWithoutPayments } = baseSettings + createSettingsStub.returns(settingsWithoutPayments) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].fees).to.deep.equal({}) + }) + + it('sets limitation.restricted_writes when plans are what admits a user', () => { + createSettingsStub.returns(withPlans()) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].limitation.restricted_writes).to.equal(true) + }) + + it('leaves limitation.restricted_writes off when plans are not in use', () => { + createSettingsStub.returns(withPlans({ subscriptionPlans: [] })) + + rootRequestHandler(req, res, next) + + expect(res.send.firstCall.args[0].limitation.restricted_writes).to.equal(false) + }) + }) + it('returns empty fees instead of crashing when the payments block is absent', () => { const { payments: _payments, ...settingsWithoutPayments } = baseSettings createSettingsStub.returns(settingsWithoutPayments) diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index 74536a565..b14562cc9 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -81,7 +81,7 @@ describe('SubscribeMessageHandler', () => { updatedAt: new Date(), })) inviteCodeRepository = { create: inviteCodeRepositoryCreateStub } as any - rateLimiterHitStub = sandbox.stub().resolves(false) + rateLimiterHitStub = sandbox.stub().resolves({ limited: false }) rateLimiter = { hit: rateLimiterHitStub } as any rateLimiterFactory = sandbox.stub().returns(rateLimiter) handler = new SubscribeMessageHandler( @@ -405,7 +405,7 @@ describe('SubscribeMessageHandler', () => { limits: { client: { subscription: {} }, invite: { rateLimits: [{ period: 3600000, rate: 5 }] } }, }), ) - rateLimiterHitStub.resolves(true) + rateLimiterHitStub.resolves({ limited: true }) await request([{ kinds: [28935] }]) diff --git a/test/unit/migrations/moderation-stats-first-seen-indexes-migration.spec.ts b/test/unit/migrations/moderation-stats-first-seen-indexes-migration.spec.ts new file mode 100644 index 000000000..9816a4064 --- /dev/null +++ b/test/unit/migrations/moderation-stats-first-seen-indexes-migration.spec.ts @@ -0,0 +1,39 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' + +import { expect } from 'chai' +import Sinon from 'sinon' + +// createRequire works whether mocha loads this spec as CommonJS or as an ES module. +const requireFromRepo = createRequire(join(process.cwd(), 'package.json')) +const migration = requireFromRepo('./migrations/20261025_120000_add_moderation_stats_first_seen_indexes.js') + +describe('migrations/20261025_120000_add_moderation_stats_first_seen_indexes', () => { + it('opts out of knex transaction so CREATE INDEX CONCURRENTLY can run', () => { + expect(migration.config).to.deep.equal({ transaction: false }) + }) + + it('exports up and down', () => { + expect(migration.up).to.be.a('function') + expect(migration.down).to.be.a('function') + }) + + it('drops an invalid leftover index before recreating it', async () => { + const statements: string[] = [] + const raw = Sinon.stub().callsFake(async (sql: string, bindings?: unknown[]) => { + statements.push(sql) + if (sql.includes('SELECT 1') && bindings?.[0] === 'events_live_first_seen_pubkey_idx') { + return { rows: [{ '?column?': 1 }] } + } + return { rows: [] } + }) + + await migration.up({ raw }) + + expect(raw.calledWith('DROP INDEX CONCURRENTLY IF EXISTS ??', ['events_live_first_seen_pubkey_idx'])).to.equal(true) + expect(raw.calledWith('DROP INDEX CONCURRENTLY IF EXISTS ??', ['events_live_first_seen_remote_address_idx'])).to.equal( + false, + ) + expect(statements.filter((sql) => sql.includes('CREATE INDEX CONCURRENTLY IF NOT EXISTS')).length).to.equal(2) + }) +}) diff --git a/test/unit/payments-processors/lnbits-payment-processor.spec.ts b/test/unit/payments-processors/lnbits-payment-processor.spec.ts index d3f12725b..0f3ba96e3 100644 --- a/test/unit/payments-processors/lnbits-payment-processor.spec.ts +++ b/test/unit/payments-processors/lnbits-payment-processor.spec.ts @@ -81,5 +81,43 @@ describe('LNbitsPaymentsProcessor', () => { expect(invoice.status).to.equal(InvoiceStatus.COMPLETED) expect(invoice.amountPaid).to.equal(42n) }) + + it('parses ISO 8601 time and expiry returned by LNbits >= 1.0', async () => { + const time = new Date(Date.now() - 120000) + const expiry = new Date(Date.now() - 60000) + const { processor } = makeProcessor( + invoiceResponse({ + details: { + time: time.toISOString(), + expiry: expiry.toISOString(), + }, + }), + ) + + const invoice = await processor.getInvoice('lnbits-payment-hash') + + expect(invoice.status).to.equal(InvoiceStatus.EXPIRED) + expect(invoice.expiresAt?.getTime()).to.equal(expiry.getTime()) + expect(invoice.createdAt?.getTime()).to.equal(time.getTime()) + }) + + it('sets a valid confirmedAt for paid invoices with an ISO 8601 time', async () => { + const { processor } = makeProcessor( + invoiceResponse({ + data: { + paid: true, + }, + details: { + time: '2025-10-11T15:24:27+00:00', + expiry: '2025-10-12T15:24:27+00:00', + }, + }), + ) + + const invoice = await processor.getInvoice('lnbits-payment-hash') + + expect(invoice.status).to.equal(InvoiceStatus.COMPLETED) + expect(invoice.confirmedAt?.toISOString()).to.equal('2025-10-11T15:24:27.000Z') + }) }) }) diff --git a/test/unit/payments-processors/lnurl-payments-processor.spec.ts b/test/unit/payments-processors/lnurl-payments-processor.spec.ts new file mode 100644 index 000000000..389e278d3 --- /dev/null +++ b/test/unit/payments-processors/lnurl-payments-processor.spec.ts @@ -0,0 +1,123 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { InvoiceStatus, InvoiceUnit, LnurlInvoice } from '../../../src/@types/invoice' +import { LnurlPaymentsProcessor } from '../../../src/payments-processors/lnurl-payments-processor' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +const PUBKEY = 'd'.repeat(64) + +describe('LnurlPaymentsProcessor', () => { + const settings = () => + ({ paymentsProcessors: { lnurl: { invoiceURL: 'https://pay.example.com/lnurlp/relay' } } }) as any + + const makeProcessor = (httpClient: any, currentSettings: () => any = settings) => + new LnurlPaymentsProcessor(httpClient, currentSettings) + + describe('createInvoice', () => { + const callbackResponse = { + data: { pr: 'lnbc210n1lnurl', verify: 'https://pay.example.com/verify/abc' }, + } + + it('asks the callback of the configured pay url for an invoice of the amount in msats', async () => { + const httpClient = { get: sinon.stub().resolves(callbackResponse) } + + await makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'Admission', requestId: PUBKEY }) + + expect(httpClient.get).to.have.been.calledOnceWithExactly( + 'https://pay.example.com/lnurlp/relay/callback?amount=21000&comment=Admission', + ) + }) + + it('maps the callback response to a pending invoice in msats with its verify url', async () => { + const httpClient = { get: sinon.stub().resolves(callbackResponse) } + const before = Date.now() + + const invoice = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'Admission', + requestId: PUBKEY, + }) + + expect(invoice.id).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + expect(invoice.pubkey).to.equal(PUBKEY) + expect(invoice.bolt11).to.equal('lnbc210n1lnurl') + expect(invoice.amountRequested).to.equal(21000n) + expect(invoice.description).to.equal('Admission') + expect(invoice.unit).to.equal(InvoiceUnit.MSATS) + expect(invoice.status).to.equal(InvoiceStatus.PENDING) + expect(invoice.verifyURL).to.equal('https://pay.example.com/verify/abc') + expect(invoice.expiresAt).to.equal(null) + expect(invoice.confirmedAt).to.equal(null) + expect(invoice.createdAt.getTime()).to.be.within(before, Date.now()) + }) + + it('gives every invoice its own id, since the provider does not', async () => { + const httpClient = { get: sinon.stub().resolves(callbackResponse) } + const processor = makeProcessor(httpClient) + + const first = await processor.createInvoice({ amount: 1000n, description: 'd', requestId: PUBKEY }) + const second = await processor.createInvoice({ amount: 1000n, description: 'd', requestId: PUBKEY }) + + expect(first.id).to.not.equal(second.id) + }) + + it('rethrows when the provider is unavailable, so no invoice is recorded', async () => { + const httpClient = { get: sinon.stub().rejects(new Error('503 service unavailable')) } + + await expect( + makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY }), + ).to.be.rejectedWith('503 service unavailable') + }) + }) + + describe('getInvoice', () => { + const lnurlInvoice = { id: 'invoice-id', verifyURL: 'https://pay.example.com/verify/abc' } as LnurlInvoice + + it('asks the verify url of the invoice whether it was paid', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { settled: false } }) } + + await makeProcessor(httpClient).getInvoice(lnurlInvoice) + + expect(httpClient.get).to.have.been.calledOnceWithExactly('https://pay.example.com/verify/abc') + }) + + it('reports a settled invoice as completed, confirmed now', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { settled: true } }) } + const before = Date.now() + + const result = await makeProcessor(httpClient).getInvoice(lnurlInvoice) + + expect(result.id).to.equal('invoice-id') + expect(result.status).to.equal(InvoiceStatus.COMPLETED) + expect(result.confirmedAt!.getTime()).to.be.within(before, Date.now()) + }) + + it('reports an unsettled invoice as pending and unconfirmed', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { settled: false } }) } + + const result = await makeProcessor(httpClient).getInvoice(lnurlInvoice) + + expect(result.status).to.equal(InvoiceStatus.PENDING) + expect(result.confirmedAt).to.equal(undefined) + }) + + it('does not report an invoice as paid when the answer has no settled field', async () => { + const httpClient = { get: sinon.stub().resolves({ data: {} }) } + + expect((await makeProcessor(httpClient).getInvoice(lnurlInvoice)).status).to.equal(InvoiceStatus.PENDING) + }) + + it('rethrows when the provider is unavailable', async () => { + const httpClient = { get: sinon.stub().rejects(new Error('ETIMEDOUT')) } + + await expect(makeProcessor(httpClient).getInvoice(lnurlInvoice)).to.be.rejectedWith('ETIMEDOUT') + }) + }) +}) diff --git a/test/unit/payments-processors/nodeless-payments-processor.spec.ts b/test/unit/payments-processors/nodeless-payments-processor.spec.ts new file mode 100644 index 000000000..560d72732 --- /dev/null +++ b/test/unit/payments-processors/nodeless-payments-processor.spec.ts @@ -0,0 +1,158 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { InvoiceStatus } from '../../../src/@types/invoice' +import { NodelessPaymentsProcessor } from '../../../src/payments-processors/nodeless-payments-processor' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +const PUBKEY = 'c'.repeat(64) + +const invoice = (overrides: any = {}) => ({ + id: 'nodeless-invoice-id', + status: 'new', + satsAmount: 21, + lightningInvoice: 'lnbc210n1nodeless', + metadata: { requestId: PUBKEY, description: 'Admission fee', unit: 'sats' }, + createdAt: '2029-12-31T23:50:00.000Z', + expiresAt: '2030-01-01T00:00:00.000Z', + paidAt: null, + ...overrides, +}) + +describe('NodelessPaymentsProcessor', () => { + const settings = () => ({ paymentsProcessors: { nodeless: { storeId: 'store-123' } } }) as any + + const makeProcessor = (httpClient: any) => new NodelessPaymentsProcessor(httpClient, settings) + + describe('createInvoice', () => { + it('posts the invoice to the store in sats, with the pubkey and description as metadata', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: invoice() }, headers: {} }) } + const before = Date.now() + + await makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'Admission fee', requestId: PUBKEY }) + + expect(httpClient.post).to.have.been.calledOnce + const [url, body, options] = httpClient.post.firstCall.args + expect(url).to.equal('/api/v1/store/store-123/invoice') + expect(options).to.deep.equal({ maxRedirects: 1 }) + expect(body).to.include({ amount: 21, currency: 'SATS' }) + expect(body.metadata).to.include({ description: 'Admission fee', requestId: PUBKEY, unit: 'sats' }) + expect(new Date(body.metadata.createdAt).getTime()).to.be.within(before, Date.now()) + }) + + it('converts msats to whole sats, dropping any remainder', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: invoice() }, headers: {} }) } + + await makeProcessor(httpClient).createInvoice({ amount: 21999n, description: 'd', requestId: PUBKEY }) + + expect(httpClient.post.firstCall.args[1].amount).to.equal(21) + }) + + it('maps the response to an invoice in sats', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: invoice() }, headers: {} }) } + + const result = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'Admission fee', + requestId: PUBKEY, + }) + + expect(result.id).to.equal('nodeless-invoice-id') + expect(result.pubkey).to.equal(PUBKEY) + expect(result.bolt11).to.equal('lnbc210n1nodeless') + expect(result.amountRequested).to.equal(21n) + expect(result.description).to.equal('Admission fee') + expect(result.unit).to.equal('sats') + expect(result.status).to.equal(InvoiceStatus.PENDING) + expect(result.expiresAt).to.deep.equal(new Date('2030-01-01T00:00:00.000Z')) + expect(result.createdAt).to.deep.equal(new Date('2029-12-31T23:50:00.000Z')) + expect(result.confirmedAt).to.equal(null) + }) + + it('expires an invoice fifteen minutes after it was created when the provider gives no expiry', async () => { + const httpClient = { + post: sinon.stub().resolves({ data: { data: invoice({ expiresAt: undefined }) }, headers: {} }), + } + + const result = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'd', + requestId: PUBKEY, + }) + + expect(result.expiresAt).to.deep.equal(new Date('2030-01-01T00:05:00.000Z')) + }) + + it('rethrows when the provider is unavailable, so no invoice is recorded', async () => { + const httpClient = { post: sinon.stub().rejects(new Error('503 service unavailable')) } + + await expect( + makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY }), + ).to.be.rejectedWith('503 service unavailable') + }) + + it('fails when the provider answers without invoice data', async () => { + const httpClient = { post: sinon.stub().resolves({ data: {}, headers: {} }) } + + await expect(makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY })).to + .be.rejected + }) + }) + + describe('getInvoice', () => { + it('reads the invoice from the configured store', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: invoice() } }) } + + await makeProcessor(httpClient).getInvoice('nodeless-invoice-id') + + expect(httpClient.get).to.have.been.calledOnceWithExactly('/api/v1/store/store-123/invoice/nodeless-invoice-id', { + maxRedirects: 1, + }) + }) + + const statuses: Array<[string, InvoiceStatus]> = [ + ['new', InvoiceStatus.PENDING], + ['pending_confirmation', InvoiceStatus.PENDING], + ['underpaid', InvoiceStatus.PENDING], + ['in_flight', InvoiceStatus.PENDING], + ['paid', InvoiceStatus.COMPLETED], + ['overpaid', InvoiceStatus.COMPLETED], + ['expired', InvoiceStatus.EXPIRED], + ] + for (const [providerStatus, status] of statuses) { + it(`reports a ${providerStatus} invoice as ${status}`, async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: invoice({ status: providerStatus }) } }) } + + expect((await makeProcessor(httpClient).getInvoice('nodeless-invoice-id')).status).to.equal(status) + }) + } + + it('treats a status it does not know as undefined rather than as paid', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: invoice({ status: 'something-new' }) } }) } + + expect((await makeProcessor(httpClient).getInvoice('nodeless-invoice-id')).status).to.equal(undefined) + }) + + it('reports when a paid invoice was confirmed', async () => { + const httpClient = { + get: sinon.stub().resolves({ data: { data: invoice({ status: 'paid', paidAt: '2030-01-01T00:05:00.000Z' }) } }), + } + + expect((await makeProcessor(httpClient).getInvoice('nodeless-invoice-id')).confirmedAt).to.deep.equal( + new Date('2030-01-01T00:05:00.000Z'), + ) + }) + + it('rethrows when the provider is unavailable', async () => { + const httpClient = { get: sinon.stub().rejects(new Error('ETIMEDOUT')) } + + await expect(makeProcessor(httpClient).getInvoice('nodeless-invoice-id')).to.be.rejectedWith('ETIMEDOUT') + }) + }) +}) diff --git a/test/unit/payments-processors/opennode-payments-processor.spec.ts b/test/unit/payments-processors/opennode-payments-processor.spec.ts new file mode 100644 index 000000000..fd92ceb2d --- /dev/null +++ b/test/unit/payments-processors/opennode-payments-processor.spec.ts @@ -0,0 +1,182 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' +import { OpenNodePaymentsProcessor } from '../../../src/payments-processors/opennode-payments-processor' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +const PUBKEY = 'b'.repeat(64) +const EXPIRES_AT_SECONDS = 1893456000 // 2030-01-01T00:00:00Z + +const charge = (overrides: any = {}) => ({ + id: 'opennode-charge-id', + order_id: PUBKEY, + amount: 21, + description: 'Admission fee', + status: 'unpaid', + lightning_invoice: { payreq: 'lnbc210n1opennode', expires_at: EXPIRES_AT_SECONDS }, + created_at: 1893455000, + ...overrides, +}) + +describe('OpenNodePaymentsProcessor', () => { + const settings = () => + ({ paymentsProcessors: { opennode: { callbackBaseURL: 'https://relay.example.com/callbacks/opennode' } } }) as any + + const makeProcessor = (httpClient: any, currentSettings: () => any = settings) => + new OpenNodePaymentsProcessor(httpClient, currentSettings) + + describe('createInvoice', () => { + it('posts the charge in sats with the pubkey as order id, the callback url and a ten minute ttl', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'Admission fee', requestId: PUBKEY }) + + expect(httpClient.post).to.have.been.calledOnceWithExactly( + '/v1/charges', + { + amount: 21, + description: 'Admission fee', + order_id: PUBKEY, + callback_url: 'https://relay.example.com/callbacks/opennode', + ttl: 10, + }, + { maxRedirects: 1 }, + ) + }) + + it('converts msats to whole sats, dropping any remainder', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).createInvoice({ amount: 21999n, description: 'd', requestId: PUBKEY }) + + expect(httpClient.post.firstCall.args[1].amount).to.equal(21) + }) + + it('maps the charge to an invoice in sats', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + const invoice = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'Admission fee', + requestId: PUBKEY, + }) + + expect(invoice.id).to.equal('opennode-charge-id') + expect(invoice.pubkey).to.equal(PUBKEY) + expect(invoice.bolt11).to.equal('lnbc210n1opennode') + expect(invoice.amountRequested).to.equal(21n) + expect(invoice.unit).to.equal(InvoiceUnit.SATS) + expect(invoice.status).to.equal(InvoiceStatus.PENDING) + expect(invoice.expiresAt).to.deep.equal(new Date(EXPIRES_AT_SECONDS * 1000)) + expect(invoice.createdAt).to.deep.equal(new Date(1893455000 * 1000)) + expect(invoice.confirmedAt).to.equal(null) + }) + + it('rethrows when the provider is unavailable, so no invoice is recorded', async () => { + const httpClient = { post: sinon.stub().rejects(new Error('502 bad gateway')) } + + await expect( + makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY }), + ).to.be.rejectedWith('502 bad gateway') + }) + + it('fails when the provider answers without charge data', async () => { + const httpClient = { post: sinon.stub().resolves({ data: {} }) } + + await expect(makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY })).to + .be.rejected + }) + }) + + describe('getInvoice', () => { + it('reads the charge by id', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).getInvoice('opennode-charge-id') + + expect(httpClient.get).to.have.been.calledOnceWithExactly('/v2/charge/opennode-charge-id', { maxRedirects: 1 }) + }) + + const statuses: Array<[string, InvoiceStatus]> = [ + ['unpaid', InvoiceStatus.PENDING], + ['processing', InvoiceStatus.PENDING], + ['underpaid', InvoiceStatus.PENDING], + ['paid', InvoiceStatus.COMPLETED], + ['expired', InvoiceStatus.EXPIRED], + ['refunded', InvoiceStatus.EXPIRED], + ] + for (const [providerStatus, status] of statuses) { + it(`reports a ${providerStatus} charge as ${status}`, async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge({ status: providerStatus }) } }) } + + expect((await makeProcessor(httpClient).getInvoice('opennode-charge-id')).status).to.equal(status) + }) + } + + it('treats a status it does not know as undefined rather than as paid', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge({ status: 'something-new' }) } }) } + + const invoice = await makeProcessor(httpClient).getInvoice('opennode-charge-id') + + expect(invoice.status).to.not.equal(InvoiceStatus.COMPLETED) + }) + + it('stamps a paid charge as confirmed now, and leaves others unconfirmed', async () => { + const paid = { get: sinon.stub().resolves({ data: { data: charge({ status: 'paid' }) } }) } + const unpaid = { get: sinon.stub().resolves({ data: { data: charge() } }) } + + const before = Date.now() + const paidInvoice = await makeProcessor(paid).getInvoice('opennode-charge-id') + + expect(paidInvoice.confirmedAt!.getTime()).to.be.within(before, Date.now()) + expect((await makeProcessor(unpaid).getInvoice('opennode-charge-id')).confirmedAt).to.equal(null) + }) + + it('reads the invoice from the lightning object when the charge has no lightning_invoice', async () => { + const httpClient = { + get: sinon.stub().resolves({ + data: { + data: charge({ + lightning_invoice: undefined, + lightning: { payreq: 'lnbc210n1fallback', expires_at: '2030-01-01T00:00:00.000Z' }, + }), + }, + }), + } + + const invoice = await makeProcessor(httpClient).getInvoice('opennode-charge-id') + + expect(invoice.bolt11).to.equal('lnbc210n1fallback') + expect(invoice.expiresAt).to.deep.equal(new Date('2030-01-01T00:00:00.000Z')) + }) + + it('uses the price when the charge has no amount', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge({ amount: undefined, price: 42 }) } }) } + + expect((await makeProcessor(httpClient).getInvoice('opennode-charge-id')).amountRequested).to.equal(42n) + }) + + it('reads a created_at that is already a date string', async () => { + const httpClient = { + get: sinon.stub().resolves({ data: { data: charge({ created_at: '2029-12-31T23:50:00.000Z' }) } }), + } + + expect((await makeProcessor(httpClient).getInvoice('opennode-charge-id')).createdAt).to.deep.equal( + new Date('2029-12-31T23:50:00.000Z'), + ) + }) + + it('rethrows when the provider is unavailable', async () => { + const httpClient = { get: sinon.stub().rejects(new Error('ETIMEDOUT')) } + + await expect(makeProcessor(httpClient).getInvoice('opennode-charge-id')).to.be.rejectedWith('ETIMEDOUT') + }) + }) +}) diff --git a/test/unit/payments-processors/zebedee-payments-processor.spec.ts b/test/unit/payments-processors/zebedee-payments-processor.spec.ts new file mode 100644 index 000000000..8b4cf8732 --- /dev/null +++ b/test/unit/payments-processors/zebedee-payments-processor.spec.ts @@ -0,0 +1,180 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { InvoiceStatus } from '../../../src/@types/invoice' +import { ZebedeePaymentsProcessor } from '../../../src/payments-processors/zebedee-payments-processor' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +const PUBKEY = 'a'.repeat(64) + +const charge = (overrides: any = {}) => ({ + id: 'zbd-charge-id', + internalId: PUBKEY, + amount: '21000', + description: 'Admission fee', + unit: 'msats', + status: 'pending', + invoice: { request: 'lnbc210n1zebedee' }, + expiresAt: '2030-01-01T00:00:00.000Z', + confirmedAt: null, + createdAt: '2029-12-31T23:50:00.000Z', + ...overrides, +}) + +describe('ZebedeePaymentsProcessor', () => { + const settings = () => + ({ paymentsProcessors: { zebedee: { callbackBaseURL: 'https://relay.example.com/callbacks/zebedee' } } }) as any + + const makeProcessor = (httpClient: any, currentSettings: () => any = settings) => + new ZebedeePaymentsProcessor(httpClient, currentSettings) + + describe('createInvoice', () => { + it('posts the charge in msats with the pubkey as internal id and the callback url', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'Admission fee', requestId: PUBKEY }) + + expect(httpClient.post).to.have.been.calledOnceWithExactly( + '/v0/charges', + { + amount: '21000', + description: 'Admission fee', + internalId: PUBKEY, + callbackUrl: 'https://relay.example.com/callbacks/zebedee', + }, + { maxRedirects: 1 }, + ) + }) + + it('sends the amount as a string so large msat amounts keep their precision', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).createInvoice({ + amount: 9007199254740993000n, + description: 'big', + requestId: PUBKEY, + }) + + expect(httpClient.post.firstCall.args[1].amount).to.equal('9007199254740993000') + }) + + it('leaves the callback url unset when the processor is not configured with one', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient, () => ({ paymentsProcessors: {} })).createInvoice({ + amount: 1000n, + description: 'd', + requestId: PUBKEY, + }) + + expect(httpClient.post.firstCall.args[1].callbackUrl).to.equal(undefined) + }) + + it('maps the charge to an invoice', async () => { + const httpClient = { post: sinon.stub().resolves({ data: { data: charge() } }) } + + const invoice = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'Admission fee', + requestId: PUBKEY, + }) + + expect(invoice.id).to.equal('zbd-charge-id') + expect(invoice.pubkey).to.equal(PUBKEY) + expect(invoice.bolt11).to.equal('lnbc210n1zebedee') + expect(invoice.amountRequested).to.equal(21000n) + expect(invoice.description).to.equal('Admission fee') + expect(invoice.unit).to.equal('msats') + expect(invoice.status).to.equal(InvoiceStatus.PENDING) + expect(invoice.expiresAt).to.deep.equal(new Date('2030-01-01T00:00:00.000Z')) + expect(invoice.createdAt).to.deep.equal(new Date('2029-12-31T23:50:00.000Z')) + expect(invoice.confirmedAt).to.equal(null) + }) + + it('leaves dates the provider does not report as null', async () => { + const httpClient = { + post: sinon.stub().resolves({ data: { data: charge({ expiresAt: null, createdAt: undefined }) } }), + } + + const invoice = await makeProcessor(httpClient).createInvoice({ + amount: 21000n, + description: 'd', + requestId: PUBKEY, + }) + + expect(invoice.expiresAt).to.equal(null) + expect(invoice.createdAt).to.equal(null) + }) + + it('rethrows when the provider is unavailable, so no invoice is recorded', async () => { + const httpClient = { post: sinon.stub().rejects(new Error('503 service unavailable')) } + + await expect( + makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY }), + ).to.be.rejectedWith('503 service unavailable') + }) + + it('fails when the provider answers without charge data', async () => { + const httpClient = { post: sinon.stub().resolves({ data: {} }) } + + await expect(makeProcessor(httpClient).createInvoice({ amount: 21000n, description: 'd', requestId: PUBKEY })).to + .be.rejected + }) + }) + + describe('getInvoice', () => { + it('reads the charge by id', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge() } }) } + + await makeProcessor(httpClient).getInvoice('zbd-charge-id') + + expect(httpClient.get).to.have.been.calledOnceWithExactly('/v0/charges/zbd-charge-id', { maxRedirects: 1 }) + }) + + // Zebedee reports its status as these literal strings, which the processor hands on as the invoice status. + const statuses: Array<[string, InvoiceStatus]> = [ + ['pending', InvoiceStatus.PENDING], + ['completed', InvoiceStatus.COMPLETED], + ['expired', InvoiceStatus.EXPIRED], + ] + for (const [providerStatus, status] of statuses) { + it(`reports a ${providerStatus} charge as ${status}`, async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge({ status: providerStatus }) } }) } + + const invoice = await makeProcessor(httpClient).getInvoice('zbd-charge-id') + + expect(invoice.status).to.equal(status) + }) + } + + it('reports when a paid charge was confirmed', async () => { + const httpClient = { + get: sinon + .stub() + .resolves({ data: { data: charge({ status: 'completed', confirmedAt: '2030-01-01T00:05:00.000Z' }) } }), + } + + const invoice = await makeProcessor(httpClient).getInvoice('zbd-charge-id') + + expect(invoice.confirmedAt).to.deep.equal(new Date('2030-01-01T00:05:00.000Z')) + }) + + it('has no confirmation date for a charge that is not paid', async () => { + const httpClient = { get: sinon.stub().resolves({ data: { data: charge() } }) } + + expect((await makeProcessor(httpClient).getInvoice('zbd-charge-id')).confirmedAt).to.equal(null) + }) + + it('rethrows when the provider is unavailable', async () => { + const httpClient = { get: sinon.stub().rejects(new Error('ETIMEDOUT')) } + + await expect(makeProcessor(httpClient).getInvoice('zbd-charge-id')).to.be.rejectedWith('ETIMEDOUT') + }) + }) +}) diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index 38c91e20a..8cba85b66 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -728,6 +728,12 @@ describe('EventRepository', () => { expect(query).to.include(ORDER_CLAUSE) }) + it('applies a row cap when the caller asks for one, after the ordering', () => { + const query = repository.findReconciliationItems({ kinds: [1] }, 101).toString() + + expect(query).to.match(/order by "events"\."event_created_at" asc, "events"\."event_id" asc limit 101$/) + }) + it('applies tag filters', () => { const query = repository.findReconciliationItems({ '#e': ['b'.repeat(64)] } as any).toString() @@ -1033,6 +1039,115 @@ describe('EventRepository', () => { ) }) + describe('with plan retention', () => { + beforeEach(() => { + sandbox.useFakeTimers(new Date('2023-11-14T22:13:20.000Z')) + }) + + // knex prints dates in the local time zone of the machine, so the moment is masked. + const purge = (options: Parameters[0]) => + repository + .deleteExpiredAndRetained(options) + .toString() + .replace(/'\d{4}-\d{2}-\d{2}[ T][^']*'/g, '') + + it('purges as before when no plan has its own retention', () => { + expect(purge({ maxDays: 7, planRetention: [] })).to.equal(purge({ maxDays: 7 })) + }) + + it('holds the subscribers of a plan to its retention and everyone else to the global one', () => { + const query = purge({ maxDays: 7, planRetention: [{ planId: 'premium', maxDays: 90 }] }) + + // 1700000000 is now; 7 days back is 1699395200, 90 days back is 1692224000 + const active = + 'not "status" = \'expired\' and (COALESCE(grace_ends_at, current_period_end) IS NULL or COALESCE(grace_ends_at, current_period_end) > )' + expect(query).to.equal( + 'delete from "events" where "event_id" in (select "event_id" from "events" where ("expires_at" < 1700000000 or "deleted_at" is not null' + + ` or ("event_created_at" < 1699395200 and "event_pubkey" not in (select "pubkey" from "user_subscriptions" where "plan_id" in ('premium') and ${active}))` + + ` or ("event_created_at" < 1692224000 and "event_pubkey" in (select "pubkey" from "user_subscriptions" where "plan_id" in ('premium') and ${active})))` + + ' and not ("event_kind" = 62) limit 1000) returning "deleted_at", "expires_at", "event_created_at"', + ) + }) + + it('keeps the whitelists in force for subscribers too', () => { + const query = purge({ + maxDays: 7, + kindWhitelist: [1], + pubkeyWhitelist: ['001122'], + planRetention: [{ planId: 'premium', maxDays: 90 }], + }) + + expect(query).to.contain('and not ("event_kind" = 1 or "event_kind" = 62) and "event_pubkey" not in (X\'001122\') limit 1000') + }) + + it('gives each plan its own window, excluding the subscribers of all of them from the global one', () => { + const query = purge({ + maxDays: 7, + planRetention: [ + { planId: 'basic', maxDays: 30 }, + { planId: 'vip', maxDays: 365 }, + ], + }) + + expect(query).to.contain(`"event_created_at" < 1699395200 and "event_pubkey" not in (select "pubkey" from "user_subscriptions" where "plan_id" in ('basic', 'vip')`) + expect(query).to.contain(`"event_created_at" < ${1700000000 - 30 * 86400} and "event_pubkey" in (select "pubkey" from "user_subscriptions" where "plan_id" in ('basic')`) + expect(query).to.contain(`"event_created_at" < ${1700000000 - 365 * 86400} and "event_pubkey" in (select "pubkey" from "user_subscriptions" where "plan_id" in ('vip')`) + }) + + it('applies a plan retention that is shorter than the global one', () => { + expect(purge({ maxDays: 90, planRetention: [{ planId: 'free', maxDays: 7 }] })).to.contain( + `"event_created_at" < 1699395200 and "event_pubkey" in (select "pubkey" from "user_subscriptions" where "plan_id" in ('free')`, + ) + }) + + it('only counts subscriptions whose access has not ended, so an expired subscriber falls back to the global window', () => { + const query = purge({ maxDays: 7, planRetention: [{ planId: 'premium', maxDays: 90 }] }) + + expect(query).to.contain('not "status" = \'expired\'') + expect(query).to.contain('COALESCE(grace_ends_at, current_period_end) IS NULL') + expect(query).to.contain('COALESCE(grace_ends_at, current_period_end) > ') + }) + + it('ignores plan retentions that are not positive numbers', () => { + const options = { maxDays: 7 } + + expect( + purge({ + ...options, + planRetention: [ + { planId: 'zero', maxDays: 0 }, + { planId: 'negative', maxDays: -5 }, + { planId: 'nan', maxDays: Number.NaN }, + ], + }), + ).to.equal(purge(options)) + }) + + it('does not purge anything when the global retention is off, whatever the plans say', async () => { + const result = await repository.deleteExpiredAndRetained({ + maxDays: 0, + planRetention: [{ planId: 'premium', maxDays: 90 }], + }) + + expect(result).to.deep.equal({ deleted: 0, expired: 0, retained: 0 }) + }) + + it('counts a row purged by a longer or shorter plan window as retained', () => { + const now = 1700000000 + const result = (repository as any).mapToPurgeCounts( + [ + // purged by a 7 day plan window although it is newer than the 90 day global one + { deleted_at: null, expires_at: null, event_created_at: now - 20 * 86400 }, + { deleted_at: null, expires_at: null, event_created_at: now - 120 * 86400 }, + ], + now, + now - 7 * 86400, + ) + + expect(result.retained).to.equal(2) + }) + }) + it('maps purged rows to deleted, expired, and retained counts', () => { const result = (repository as any).mapToPurgeCounts( [ diff --git a/test/unit/repositories/invoice-repository.spec.ts b/test/unit/repositories/invoice-repository.spec.ts index 7dedc4b43..281abd08c 100644 --- a/test/unit/repositories/invoice-repository.spec.ts +++ b/test/unit/repositories/invoice-repository.spec.ts @@ -114,6 +114,35 @@ describe('InvoiceRepository', () => { expect(result!.amountRequested).to.equal(1000n) }) + it('maps the subscription plan and period when the invoice has them', async () => { + const periodStart = new Date('2026-02-01T00:00:00.000Z') + const periodEnd = new Date('2026-03-03T00:00:00.000Z') + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ + select: sandbox + .stub() + .resolves([{ ...dbInvoiceRow, plan_id: 'basic', period_start: periodStart, period_end: periodEnd }]), + }), + }) as unknown as DatabaseClient + + const result = await repository.findById('test-invoice-id', client) + + expect(result!.planId).to.equal('basic') + expect(result!.periodStart).to.equal(periodStart) + expect(result!.periodEnd).to.equal(periodEnd) + }) + + it('leaves the plan unset for an admission invoice', async () => { + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: sandbox.stub().resolves([dbInvoiceRow]) }), + }) as unknown as DatabaseClient + + const result = await repository.findById('test-invoice-id', client) + + expect(result!.planId).to.equal(undefined) + expect(result!.periodEnd).to.equal(undefined) + }) + it('queries invoices table by id', async () => { const whereStub = sandbox.stub().returns({ select: sandbox.stub().resolves([]) }) const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient @@ -256,6 +285,29 @@ describe('InvoiceRepository', () => { expect(sql).to.include(`X'${pubkeyHex}'`) }) + it('stores the subscription plan and period an invoice pays for', () => { + const periodStart = new Date('2026-02-01T00:00:00.000Z') + const periodEnd = new Date('2026-03-03T00:00:00.000Z') + const sql = repository.upsert({ ...testInvoice, planId: 'basic', periodStart, periodEnd }).toString() + + expect(sql).to.include('"plan_id"') + expect(sql).to.include(`'basic'`) + expect(sql).to.include('"period_start"') + expect(sql).to.include('"period_end"') + }) + + it('never rewrites the plan or period of an existing invoice', () => { + const sql = repository + .upsert({ ...testInvoice, planId: 'basic', periodStart: fixedDate, periodEnd: fixedDate }) + .toString() + const update = sql.split('do update set')[1] + + expect(update).to.include('"status"') + expect(update).to.not.include('"plan_id"') + expect(update).to.not.include('"period_start"') + expect(update).to.not.include('"period_end"') + }) + it('includes all required invoice fields', () => { const sql = repository.upsert(testInvoice).toString() diff --git a/test/unit/repositories/nip05-verification-repository.spec.ts b/test/unit/repositories/nip05-verification-repository.spec.ts new file mode 100644 index 000000000..03ade9cde --- /dev/null +++ b/test/unit/repositories/nip05-verification-repository.spec.ts @@ -0,0 +1,272 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { DBNip05Verification, Nip05Verification } from '../../../src/@types/nip05' +import { Nip05VerificationRepository } from '../../../src/repositories/nip05-verification-repository' +import { createRecordingKnex } from './recording-knex' + +describe('Nip05VerificationRepository', () => { + const NOW = new Date('2026-06-15T12:00:00.000Z') + const DAY_MS = 86_400_000 + const pubkeyHex = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + + let clock: Sinon.SinonFakeTimers + let queries: ReturnType['queries'] + let respondWith: ReturnType['respondWith'] + let repository: Nip05VerificationRepository + + const row = (overrides: Partial = {}): DBNip05Verification => ({ + pubkey: Buffer.from(pubkeyHex, 'hex'), + nip05: 'alice@example.com', + domain: 'example.com', + is_verified: true, + last_verified_at: new Date('2026-06-14T12:00:00.000Z'), + last_checked_at: new Date('2026-06-14T12:00:00.000Z'), + failure_count: 0, + created_at: new Date('2026-06-01T00:00:00.000Z'), + updated_at: new Date('2026-06-14T12:00:00.000Z'), + ...overrides, + }) + + const verification = (overrides: Partial = {}): Nip05Verification => ({ + pubkey: pubkeyHex, + nip05: 'alice@example.com', + domain: 'example.com', + isVerified: true, + lastVerifiedAt: new Date('2026-06-14T12:00:00.000Z'), + lastCheckedAt: new Date('2026-06-14T12:00:00.000Z'), + failureCount: 0, + createdAt: new Date('2026-06-01T00:00:00.000Z'), + updatedAt: new Date('2026-06-14T12:00:00.000Z'), + ...overrides, + }) + + beforeEach(() => { + clock = Sinon.useFakeTimers({ now: NOW, toFake: ['Date'] }) + const recording = createRecordingKnex() + queries = recording.queries + respondWith = recording.respondWith + repository = new Nip05VerificationRepository(recording.db as any) + }) + + afterEach(() => { + clock.restore() + }) + + describe('.findByPubkey', () => { + it('returns undefined for a pubkey that was never verified', async () => { + respondWith([]) + + expect(await repository.findByPubkey(pubkeyHex)).to.equal(undefined) + }) + + it('looks the pubkey up by its bytes and maps the row', async () => { + respondWith([row({ is_verified: false, failure_count: 2, last_verified_at: null })]) + + const found = await repository.findByPubkey(pubkeyHex) + + expect(queries[0].sql).to.equal('select * from "nip05_verifications" where "pubkey" = $1') + expect(queries[0].bindings).to.deep.equal([Buffer.from(pubkeyHex, 'hex')]) + expect(found).to.deep.equal( + verification({ + isVerified: false, + failureCount: 2, + lastVerifiedAt: null, + }), + ) + }) + }) + + describe('.upsert', () => { + it('inserts the verification, and on a second one for the same pubkey updates everything but the pubkey and creation time', async () => { + respondWith(1) + + await repository.upsert(verification({ isVerified: false, failureCount: 3 })) + + const { sql } = queries[0] + expect(sql).to.match(/^insert into "nip05_verifications" /) + expect(sql).to.include('on conflict ("pubkey") do update set') + const update = sql.slice(sql.indexOf('do update set')) + for (const column of [ + 'nip05', + 'domain', + 'is_verified', + 'last_verified_at', + 'last_checked_at', + 'failure_count', + 'updated_at', + ]) { + expect(update, column).to.include(`"${column}" =`) + } + expect(update).to.not.include('"pubkey" =') + expect(update).to.not.include('"created_at" =') + }) + + it('stores the values it was given, stamping creation and update now', async () => { + respondWith(1) + + await repository.upsert(verification({ failureCount: 3 })) + + const { bindings } = queries[0] + expect(bindings).to.deep.include(Buffer.from(pubkeyHex, 'hex')) + expect(bindings).to.include.members(['alice@example.com', 'example.com', true, 3]) + expect(bindings.filter((binding) => binding instanceof Date && binding.getTime() === NOW.getTime())).to.not.be + .empty + }) + + it('checks now when the verification says nothing about when it was last checked, on insert and on conflict', async () => { + respondWith(1) + const checkedAt = new Date('2026-06-10T00:00:00.000Z') + + await repository.upsert(verification({ lastCheckedAt: undefined as unknown as Date })) + await repository.upsert(verification({ lastCheckedAt: checkedAt })) + + const written = (query: (typeof queries)[number]) => { + const insertColumns = /^insert into "nip05_verifications" \(([^)]*)\)/.exec(query.sql)![1].split(', ') + const inserted = insertColumns.indexOf('"last_checked_at"') + const updated = /"last_checked_at" = \$(\d+)/.exec(query.sql.slice(query.sql.indexOf('do update set')))![1] + + return [query.bindings[inserted], query.bindings[Number(updated) - 1]] + } + expect(written(queries[0])).to.deep.equal([NOW, NOW]) + expect(written(queries[1])).to.deep.equal([checkedAt, checkedAt]) + }) + + it('resolves with the number of rows written', async () => { + respondWith(1) + + expect(await repository.upsert(verification())).to.equal(1) + }) + }) + + describe('.findPendingVerifications', () => { + it('selects verifications not checked within the update frequency and not yet failing too often, least recently checked first', async () => { + respondWith([row()]) + + const pending = await repository.findPendingVerifications(DAY_MS, 10, 50) + + expect(queries[0].sql).to.equal( + 'select * from "nip05_verifications" where "last_checked_at" < $1 and "failure_count" < $2 order by "last_checked_at" asc limit $3', + ) + expect(queries[0].bindings).to.deep.equal([new Date(NOW.getTime() - DAY_MS), 10, 50]) + expect(pending).to.deep.equal([verification()]) + }) + + it('gives up on a verification once it has failed the maximum number of times', async () => { + respondWith([]) + + await repository.findPendingVerifications(DAY_MS, 3, 50) + + expect(queries[0].sql).to.include('"failure_count" < $2') + expect(queries[0].bindings[1]).to.equal(3) + }) + }) + + describe('.deleteByPubkey', () => { + it('deletes the verification of the pubkey and returns how many rows went', async () => { + respondWith(1) + + expect(await repository.deleteByPubkey(pubkeyHex)).to.equal(1) + + expect(queries[0].sql).to.equal('delete from "nip05_verifications" where "pubkey" = $1') + expect(queries[0].bindings).to.deep.equal([Buffer.from(pubkeyHex, 'hex')]) + }) + }) + + describe('.listForAdmin', () => { + const query = { verifyExpirationMs: 7 * DAY_MS, limit: 25, offset: 50 } + + // Each list is two statements: the count, then the page. + const list = (filters: Partial[0]> = {}, total = 0) => { + respondWith([{ count: String(total) }], []) + + return repository.listForAdmin({ ...query, ...filters }) + } + + it('counts, then returns the page newest check first, with the limit and offset', async () => { + respondWith([{ count: '120' }], [row()]) + + const result = await repository.listForAdmin(query) + + expect(queries).to.have.length(2) + expect(queries[0].sql).to.equal('select count(*) as "count" from "nip05_verifications" limit $1') + expect(queries[1].sql).to.equal( + 'select * from "nip05_verifications" order by "last_checked_at" desc limit $1 offset $2', + ) + expect(queries[1].bindings).to.deep.equal([25, 50]) + expect(result.total).to.equal(120) + expect(result.rows).to.deep.equal([verification()]) + }) + + it('reports a total of zero for an empty table', async () => { + respondWith([], []) + + expect(await repository.listForAdmin(query)).to.deep.equal({ rows: [], total: 0 }) + }) + + it('filters by domain, in the count and in the page', async () => { + await list({ domain: 'example.com' }) + + for (const { sql, bindings } of queries) { + expect(sql).to.include('where "domain" = $1') + expect(bindings[0]).to.equal('example.com') + } + }) + + it('filters verified ones, and unverified ones', async () => { + await list({ verified: true }) + await list({ verified: false }) + + expect(queries[0].sql).to.include('where "is_verified" = $1') + expect(queries[0].bindings[0]).to.equal(true) + expect(queries[2].bindings[0]).to.equal(false) + }) + + it('filters by a minimum failure count, including zero', async () => { + await list({ minFailures: 3 }) + await list({ minFailures: 0 }) + + expect(queries[0].sql).to.include('where "failure_count" >= $1') + expect(queries[0].bindings[0]).to.equal(3) + expect(queries[2].bindings[0]).to.equal(0) + }) + + it('treats a verification as stale when it is unverified, was never verified, or was verified before the expiry', async () => { + await list({ stale: true }) + + expect(queries[0].sql).to.include( + 'where ("is_verified" = $1 or "last_verified_at" is null or "last_verified_at" < $2)', + ) + expect(queries[0].bindings.slice(0, 2)).to.deep.equal([false, new Date(NOW.getTime() - 7 * DAY_MS)]) + }) + + it('treats a verification as fresh when it is verified and was verified since the expiry', async () => { + await list({ stale: false }) + + expect(queries[0].sql).to.include('where "is_verified" = $1 and "last_verified_at" >= $2') + expect(queries[0].bindings.slice(0, 2)).to.deep.equal([true, new Date(NOW.getTime() - 7 * DAY_MS)]) + }) + + it('does not filter on staleness unless asked to', async () => { + await list() + + expect(queries[0].sql).to.not.include('last_verified_at') + }) + + it('combines the filters with and', async () => { + await list({ domain: 'example.com', verified: true, minFailures: 2, stale: false }) + + const { sql, bindings } = queries[1] + expect(sql).to.include( + 'where "domain" = $1 and "is_verified" = $2 and "failure_count" >= $3 and "is_verified" = $4 and "last_verified_at" >= $5', + ) + expect(bindings.slice(0, 4)).to.deep.equal(['example.com', true, 2, true]) + }) + + it('pages with the filters applied to the count too, so the total matches the filtered list', async () => { + await list({ domain: 'example.com' }, 7) + + expect(queries[0].sql).to.match(/^select count\(\*\) as "count" from "nip05_verifications" where "domain" = \$1/) + }) + }) +}) diff --git a/test/unit/repositories/notification-delivery-log-repository.spec.ts b/test/unit/repositories/notification-delivery-log-repository.spec.ts new file mode 100644 index 000000000..bfbea8308 --- /dev/null +++ b/test/unit/repositories/notification-delivery-log-repository.spec.ts @@ -0,0 +1,201 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { DBNotificationDeliveryLogEntry, NotificationDeliveryStatus } from '../../../src/@types/operator-notifications' +import { NotificationDeliveryLogRepository } from '../../../src/repositories/notification-delivery-log-repository' +import { createRecordingKnex } from './recording-knex' + +describe('NotificationDeliveryLogRepository', () => { + const NOW = new Date('2026-06-15T12:00:00.000Z') + let clock: Sinon.SinonFakeTimers + let queries: ReturnType['queries'] + let respondWith: ReturnType['respondWith'] + let repository: NotificationDeliveryLogRepository + + const row = (overrides: Partial = {}): DBNotificationDeliveryLogEntry => ({ + id: '7', + outbox_id: 'outbox-1', + event_type: 'admission.invoice.paid', + target_id: 'ops-slack', + target_type: 'slack', + status: NotificationDeliveryStatus.SUCCESS, + attempt_number: 1, + error_snippet: null, + created_at: new Date('2026-06-15T11:59:00.000Z'), + ...overrides, + }) + + beforeEach(() => { + clock = Sinon.useFakeTimers({ now: NOW, toFake: ['Date'] }) + const recording = createRecordingKnex() + queries = recording.queries + respondWith = recording.respondWith + repository = new NotificationDeliveryLogRepository(recording.db as any) + }) + + afterEach(() => { + clock.restore() + }) + + describe('.append', () => { + it('records a success with the outbox message, the target and the attempt, stamped now', async () => { + respondWith(1) + + await repository.append({ + outboxId: 'outbox-1', + eventType: 'admission.invoice.paid', + targetId: 'ops-slack', + targetType: 'slack', + status: NotificationDeliveryStatus.SUCCESS, + attemptNumber: 1, + errorSnippet: null, + }) + + expect(queries).to.have.length(1) + expect(queries[0].sql).to.match(/^insert into "notification_delivery_log" /) + expect(queries[0].sql).to.include( + '("attempt_number", "created_at", "error_snippet", "event_type", "outbox_id", "status", "target_id", "target_type")', + ) + expect(queries[0].bindings).to.deep.equal([ + 1, + NOW, + null, + 'admission.invoice.paid', + 'outbox-1', + NotificationDeliveryStatus.SUCCESS, + 'ops-slack', + 'slack', + ]) + }) + + it('records a failure with the error it ended in', async () => { + respondWith(1) + + await repository.append({ + outboxId: 'outbox-1', + eventType: 'settings.changed', + targetId: 'ops-http', + targetType: 'http', + status: NotificationDeliveryStatus.FAILED, + attemptNumber: 3, + errorSnippet: 'HTTP 503', + }) + + expect(queries[0].bindings).to.include.members([3, 'HTTP 503', NotificationDeliveryStatus.FAILED, 'ops-http']) + }) + + it('records a test notification, which has no outbox message', async () => { + respondWith(1) + + await repository.append({ + outboxId: null, + eventType: 'test', + targetId: 'ops-http', + targetType: 'http', + status: NotificationDeliveryStatus.SUCCESS, + attemptNumber: 1, + errorSnippet: null, + }) + + expect(queries[0].bindings).to.include(null) + expect(queries[0].bindings).to.not.include('outbox-1') + }) + }) + + describe('.findSuccessfulTargetIds', () => { + it('lists, once each, the targets an outbox message was already delivered to, so a retry skips them', async () => { + respondWith([{ target_id: 'ops-slack' }, { target_id: 'ops-http' }]) + + const targetIds = await repository.findSuccessfulTargetIds('outbox-1') + + expect(targetIds).to.deep.equal(['ops-slack', 'ops-http']) + expect(queries[0].sql).to.include('select distinct "target_id"') + expect(queries[0].sql).to.include('from "notification_delivery_log"') + expect(queries[0].sql).to.include('"outbox_id" = $1 and "status" = $2') + expect(queries[0].bindings).to.deep.equal(['outbox-1', NotificationDeliveryStatus.SUCCESS]) + }) + + it('returns nothing for a message that was never delivered', async () => { + respondWith([]) + + expect(await repository.findSuccessfulTargetIds('outbox-1')).to.deep.equal([]) + }) + }) + + describe('.findRecent', () => { + it('returns the latest 50 entries, newest first, when asked for no filter or limit', async () => { + respondWith([row()]) + + await repository.findRecent() + + expect(queries[0].sql).to.equal('select * from "notification_delivery_log" order by "created_at" desc limit $1') + expect(queries[0].bindings).to.deep.equal([50]) + }) + + it('honours the limit', async () => { + respondWith([]) + + await repository.findRecent(5) + + expect(queries[0].bindings).to.deep.equal([5]) + }) + + it('filters by status', async () => { + respondWith([]) + + await repository.findRecent(50, { status: NotificationDeliveryStatus.FAILED }) + + expect(queries[0].sql).to.include('where "status" = $1') + expect(queries[0].bindings).to.deep.equal([NotificationDeliveryStatus.FAILED, 50]) + }) + + it('filters by event type', async () => { + respondWith([]) + + await repository.findRecent(50, { eventType: 'settings.changed' }) + + expect(queries[0].sql).to.include('where "event_type" = $1') + expect(queries[0].bindings).to.deep.equal(['settings.changed', 50]) + }) + + it('combines both filters', async () => { + respondWith([]) + + await repository.findRecent(50, { status: NotificationDeliveryStatus.SUCCESS, eventType: 'settings.changed' }) + + expect(queries[0].sql).to.include('where "status" = $1 and "event_type" = $2') + expect(queries[0].bindings).to.deep.equal([NotificationDeliveryStatus.SUCCESS, 'settings.changed', 50]) + }) + + it('maps rows to entries', async () => { + respondWith([row({ status: NotificationDeliveryStatus.FAILED, attempt_number: 2, error_snippet: 'HTTP 500' })]) + + const [entry] = await repository.findRecent() + + expect(entry).to.deep.equal({ + id: '7', + outboxId: 'outbox-1', + eventType: 'admission.invoice.paid', + targetId: 'ops-slack', + targetType: 'slack', + status: NotificationDeliveryStatus.FAILED, + attemptNumber: 2, + errorSnippet: 'HTTP 500', + createdAt: new Date('2026-06-15T11:59:00.000Z'), + }) + }) + }) + + describe('.deleteOlderThan', () => { + const cutoff = new Date('2026-05-15T00:00:00.000Z') + + it('deletes entries logged before the cutoff and returns how many', async () => { + respondWith(12) + + expect(await repository.deleteOlderThan(cutoff)).to.equal(12) + + expect(queries[0].sql).to.equal('delete from "notification_delivery_log" where "created_at" < $1') + expect(queries[0].bindings).to.deep.equal([cutoff]) + }) + }) +}) diff --git a/test/unit/repositories/notification-outbox-repository.spec.ts b/test/unit/repositories/notification-outbox-repository.spec.ts new file mode 100644 index 000000000..120bbe316 --- /dev/null +++ b/test/unit/repositories/notification-outbox-repository.spec.ts @@ -0,0 +1,318 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { DBNotificationOutboxMessage, NotificationOutboxStatus } from '../../../src/@types/notification-outbox' +import { + NOTIFICATION_OUTBOX_PROCESSING_LEASE_MS, + NotificationOutboxRepository, +} from '../../../src/repositories/notification-outbox-repository' +import { createRecordingKnex } from './recording-knex' + +describe('NotificationOutboxRepository', () => { + const NOW = new Date('2026-06-15T12:00:00.000Z') + let clock: Sinon.SinonFakeTimers + let queries: ReturnType['queries'] + let steps: ReturnType['steps'] + let stepConnections: ReturnType['stepConnections'] + let respondWith: ReturnType['respondWith'] + let repository: NotificationOutboxRepository + + const row = (overrides: Partial = {}): DBNotificationOutboxMessage => ({ + id: '6d1c9d4e-61a0-4a52-90a4-0c7f9a1c2b11', + event_type: 'admission.invoice.paid', + payload: { invoiceId: 'inv-1' }, + status: NotificationOutboxStatus.PENDING, + attempt_count: 0, + available_at: new Date('2026-06-15T11:00:00.000Z'), + last_error: null, + delivered_at: null, + created_at: new Date('2026-06-15T10:00:00.000Z'), + updated_at: new Date('2026-06-15T10:00:00.000Z'), + ...overrides, + }) + + beforeEach(() => { + clock = Sinon.useFakeTimers({ now: NOW, toFake: ['Date'] }) + const recording = createRecordingKnex() + queries = recording.queries + steps = recording.steps + stepConnections = recording.stepConnections + respondWith = recording.respondWith + repository = new NotificationOutboxRepository(recording.db as any) + }) + + afterEach(() => { + clock.restore() + }) + + describe('.enqueue', () => { + it('inserts a pending message that is available now and has not been attempted', async () => { + respondWith(1) + + const message = await repository.enqueue('admission.invoice.paid', { invoiceId: 'inv-1' }) + + expect(queries).to.have.length(1) + expect(queries[0].sql).to.match(/^insert into "notification_outbox"/) + expect(queries[0].bindings).to.include.members(['admission.invoice.paid', NotificationOutboxStatus.PENDING, 0]) + expect(message).to.include({ + eventType: 'admission.invoice.paid', + status: NotificationOutboxStatus.PENDING, + attemptCount: 0, + lastError: null, + deliveredAt: null, + }) + expect(message.payload).to.deep.equal({ invoiceId: 'inv-1' }) + expect(message.availableAt).to.deep.equal(NOW) + expect(message.createdAt).to.deep.equal(NOW) + expect(message.updatedAt).to.deep.equal(NOW) + }) + + it('gives every message its own id', async () => { + respondWith(1, 1) + + const first = await repository.enqueue('a', {}) + const second = await repository.enqueue('a', {}) + + expect(first.id).to.match(/^[0-9a-f-]{36}$/) + expect(first.id).to.not.equal(second.id) + }) + + it('does not swallow a failed insert, so the caller can roll back with it', async () => { + const failing = createRecordingKnex() + ;(failing.db.client as any).acquireConnection = async () => { + throw new Error('connection refused') + } + + let error: Error | undefined + try { + await new NotificationOutboxRepository(failing.db as any).enqueue('a', {}) + } catch (caught) { + error = caught as Error + } + + expect(error?.message).to.contain('connection refused') + }) + }) + + describe('.claimBatch', () => { + it('returns nothing, and updates nothing, when no message is due', async () => { + respondWith([]) + + expect(await repository.claimBatch(10)).to.deep.equal([]) + + expect(queries).to.have.length(1) + expect(queries[0].sql).to.match(/^select/) + }) + + it('selects due pending messages or processing ones whose lease ran out, oldest first, up to the limit', async () => { + respondWith([]) + + await repository.claimBatch(7) + + const { sql, bindings } = queries[0] + expect(sql).to.include( + 'where (("status" = $1 and "available_at" <= CURRENT_TIMESTAMP) or ("status" = $2 and "updated_at" < $3))', + ) + expect(sql).to.include('order by "created_at" asc') + expect(sql).to.include('limit $4') + expect(bindings[0]).to.equal(NotificationOutboxStatus.PENDING) + expect(bindings[1]).to.equal(NotificationOutboxStatus.PROCESSING) + expect(bindings[3]).to.equal(7) + }) + + it('reclaims a processing message once it has been held for longer than the lease', async () => { + respondWith([]) + + await repository.claimBatch(1) + + const staleBefore = queries[0].bindings[2] as Date + expect(staleBefore).to.deep.equal(new Date(NOW.getTime() - NOTIFICATION_OUTBOX_PROCESSING_LEASE_MS)) + expect(NOTIFICATION_OUTBOX_PROCESSING_LEASE_MS).to.equal(5 * 60 * 1000) + }) + + it('locks the rows it selects and skips rows another worker holds, so no message goes to two workers', async () => { + respondWith([]) + + await repository.claimBatch(1) + + expect(queries[0].sql).to.match(/for update skip locked$/) + }) + + it('marks the selected messages as processing and returns them that way', async () => { + const first = row({ id: 'id-1' }) + const second = row({ id: 'id-2', status: NotificationOutboxStatus.PROCESSING }) + respondWith([first, second], 2) + + const claimed = await repository.claimBatch(10) + + expect(queries).to.have.length(2) + expect(queries[1].sql).to.match( + /^update "notification_outbox" set "status" = \$1, "updated_at" = \$2 where "id" in \(\$3, \$4\)$/, + ) + expect(queries[1].bindings).to.deep.equal([NotificationOutboxStatus.PROCESSING, NOW, 'id-1', 'id-2']) + expect(claimed.map((message) => message.id)).to.deep.equal(['id-1', 'id-2']) + for (const message of claimed) { + expect(message.status).to.equal(NotificationOutboxStatus.PROCESSING) + expect(message.updatedAt).to.deep.equal(NOW) + } + }) + + it('selects and marks the messages in one transaction, so the rows stay locked until they are marked', async () => { + respondWith([row({ id: 'id-1' })], 1) + + await repository.claimBatch(10) + + expect(steps).to.deep.equal(['begin', 'select', 'update', 'commit']) + expect(new Set(stepConnections).size, 'every statement on the same connection').to.equal(1) + }) + + it('commits the transaction when there was nothing to claim', async () => { + respondWith([]) + + await repository.claimBatch(10) + + expect(steps).to.deep.equal(['begin', 'select', 'commit']) + }) + + it('rolls the claim back when marking the messages fails, so they are not left half claimed', async () => { + respondWith([row({ id: 'id-1' })], new Error('update failed')) + + let error: Error | undefined + try { + await repository.claimBatch(10) + } catch (caught) { + error = caught as Error + } + + expect(error?.message).to.contain('update failed') + expect(steps).to.deep.equal(['begin', 'select', 'update', 'rollback']) + }) + + it('maps the row, and leaves the attempt count for the caller to advance', async () => { + respondWith([row({ attempt_count: 2, last_error: 'timeout' })], 1) + + const [message] = await repository.claimBatch(1) + + expect(message).to.include({ + eventType: 'admission.invoice.paid', + attemptCount: 2, + lastError: 'timeout', + }) + expect(message.payload).to.deep.equal({ invoiceId: 'inv-1' }) + expect(message.availableAt).to.deep.equal(new Date('2026-06-15T11:00:00.000Z')) + }) + }) + + describe('.markDelivered', () => { + it('marks the message delivered now and clears its last error', async () => { + respondWith(1) + + await repository.markDelivered('id-1') + + expect(queries).to.have.length(1) + expect(queries[0].sql).to.match(/^update "notification_outbox" set /) + expect(queries[0].sql).to.include('"status" = $1') + expect(queries[0].sql).to.include('"delivered_at" = $2') + expect(queries[0].sql).to.include('"last_error" = $3') + expect(queries[0].sql).to.match(/where "id" = \$5$/) + expect(queries[0].bindings).to.deep.equal([NotificationOutboxStatus.DELIVERED, NOW, null, NOW, 'id-1']) + }) + }) + + describe('.markFailed', () => { + const update = () => { + const [{ sql, bindings }] = queries + const columns = [...sql.matchAll(/"(\w+)" = \$(\d+)/g)].reduce( + (all, [, column, position]) => ({ ...all, [column]: bindings[Number(position) - 1] }), + {} as Record, + ) + + return columns + } + + beforeEach(() => { + respondWith(1) + }) + + it('puts the message back to pending with the attempt count and the error', async () => { + await repository.markFailed('id-1', 'HTTP 500', 1, 5, 1000) + + expect(update()).to.include({ + status: NotificationOutboxStatus.PENDING, + attempt_count: 1, + last_error: 'HTTP 500', + }) + expect(queries[0].bindings[queries[0].bindings.length - 1]).to.equal('id-1') + }) + + it('waits the base delay after the first failure and doubles it after each one', async () => { + const waits: number[] = [] + for (const attempt of [1, 2, 3, 4]) { + queries.length = 0 + respondWith(1) + + await repository.markFailed('id-1', 'e', attempt, 10, 1000) + + waits.push((update().available_at as Date).getTime() - NOW.getTime()) + } + + expect(waits).to.deep.equal([1000, 2000, 4000, 8000]) + }) + + it('never waits more than a minute', async () => { + await repository.markFailed('id-1', 'e', 9, 10, 1000) + + expect((update().available_at as Date).getTime() - NOW.getTime()).to.equal(60_000) + }) + + it('does not wait at all when the base delay is negative', async () => { + await repository.markFailed('id-1', 'e', 3, 10, -500) + + expect(update().available_at).to.deep.equal(NOW) + }) + + it('gives up once the attempts are used up, marking the message dead, available now', async () => { + await repository.markFailed('id-1', 'HTTP 500', 5, 5, 1000) + + expect(update()).to.include({ status: NotificationOutboxStatus.DEAD, attempt_count: 5 }) + expect(update().available_at).to.deep.equal(NOW) + }) + + it('keeps retrying one attempt before the limit', async () => { + await repository.markFailed('id-1', 'e', 4, 5, 1000) + + expect(update().status).to.equal(NotificationOutboxStatus.PENDING) + }) + + it('keeps no more than 2000 characters of the error', async () => { + await repository.markFailed('id-1', 'x'.repeat(5000), 1, 5, 1000) + + expect(update().last_error).to.equal('x'.repeat(2000)) + }) + }) + + describe('.deleteTerminalOlderThan', () => { + const cutoff = new Date('2026-05-15T00:00:00.000Z') + + it('deletes delivered and dead messages last touched before the cutoff, never pending or processing ones', async () => { + respondWith(4) + + await repository.deleteTerminalOlderThan(cutoff) + + expect(queries[0].sql).to.match( + /^delete from "notification_outbox" where "status" in \(\$1, \$2\) and "updated_at" < \$3$/, + ) + expect(queries[0].bindings).to.deep.equal([ + NotificationOutboxStatus.DELIVERED, + NotificationOutboxStatus.DEAD, + cutoff, + ]) + }) + + it('returns how many it deleted', async () => { + respondWith(4) + + expect(await repository.deleteTerminalOlderThan(cutoff)).to.equal(4) + }) + }) +}) diff --git a/test/unit/repositories/recording-knex.ts b/test/unit/repositories/recording-knex.ts new file mode 100644 index 000000000..88517b511 --- /dev/null +++ b/test/unit/repositories/recording-knex.ts @@ -0,0 +1,82 @@ +import knex, { Knex } from 'knex' + +// knex does not export its postgres dialect, and a transaction builds its client from the dialect's prototype, +// so a subclass is the only way to answer queries for the transaction too. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const ClientPG = require('knex/lib/dialects/postgres') + +/** The statements that ran, in order, with a transaction's opening and closing among them. */ +export type RecordedStep = 'begin' | 'commit' | 'rollback' | string + +export interface RecordedQuery { + sql: string + bindings: readonly unknown[] +} + +/** + * A real knex query builder on the postgres dialect that never connects: it records each statement and + * answers it with the next result `respondWith` was given. Rows for a select, a row count for an update or delete, + * or an error to fail the statement with. + */ +export const createRecordingKnex = () => { + const queries: RecordedQuery[] = [] + const steps: RecordedStep[] = [] + // The connection each step ran on, in the same order: statements of one transaction share one. + const stepConnections: number[] = [] + let connections = 0 + const responses: unknown[] = [] + + class RecordingClient extends ClientPG { + public async acquireConnection() { + return { id: ++connections } + } + + public async releaseConnection() { + return undefined + } + + public _query(connection: { id: number }, query: any) { + // A transaction opens and closes with statements of its own. They are kept apart from `queries`, which + // holds only what the code under test asked for, and recorded in `steps` to show what ran inside one. + const boundary = /^\s*(begin|commit|rollback)/i.exec(query.sql) + if (boundary) { + steps.push(boundary[1].toLowerCase()) + stepConnections.push(connection.id) + query.response = { command: 'COMMIT', rows: [], rowCount: 0 } + return Promise.resolve(query) + } + + queries.push({ sql: query.sql, bindings: query.bindings ?? [] }) + steps.push(query.sql.trim().split(' ')[0].toLowerCase()) + stepConnections.push(connection.id) + + const response = responses.length ? responses.shift() : [] + if (response instanceof Error) { + return Promise.reject(response) + } + + const isRows = Array.isArray(response) + query.response = { + command: /^\s*(update|delete|insert)/i.test(query.sql) + ? query.sql.trim().split(' ')[0].toUpperCase() + : 'SELECT', + rows: isRows ? response : [], + rowCount: isRows ? (response as unknown[]).length : response, + } + + return Promise.resolve(query) + } + } + + const db = knex({ client: RecordingClient as any }) + + return { + db: db as Knex, + queries, + steps, + stepConnections, + respondWith: (...results: unknown[]) => { + responses.push(...results) + }, + } +} diff --git a/test/unit/repositories/user-subscription-repository.spec.ts b/test/unit/repositories/user-subscription-repository.spec.ts new file mode 100644 index 000000000..d6b59c366 --- /dev/null +++ b/test/unit/repositories/user-subscription-repository.spec.ts @@ -0,0 +1,256 @@ +import * as chai from 'chai' +import knex from 'knex' + +import { DatabaseClient } from '../../../src/@types/base' +import { DBUserSubscription, SubscriptionStatus } from '../../../src/@types/user-subscription' +import { UserSubscriptionRepository } from '../../../src/repositories/user-subscription-repository' + +const { expect } = chai + +describe('UserSubscriptionRepository', () => { + const pubkeyHex = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + const start = new Date('2026-01-01T00:00:00.000Z') + const end = new Date('2026-01-31T00:00:00.000Z') + + const row: DBUserSubscription = { + pubkey: Buffer.from(pubkeyHex, 'hex'), + plan_id: 'basic', + status: SubscriptionStatus.ACTIVE, + current_period_start: start, + current_period_end: end, + grace_ends_at: null, + cancel_at_period_end: false, + created_at: start, + updated_at: start, + } + + const real = knex({ client: 'pg' }) + let sql: string[] + + // How the driver prints a date in a statement. It prints it in the local time zone of the machine, so what a test + // expects is the same date printed the same way, not a literal that holds in one time zone only. + const printed = (date: Date): string => real.raw('?', [date]).toString() + + // A client that builds real knex queries, records the SQL they would run and answers with `result`. + const clientReturning = (result: unknown): DatabaseClient => + ((table: string) => { + const builder: any = real(table) + builder.then = (resolve: (value: unknown) => unknown) => { + sql.push(builder.toString()) + return Promise.resolve(result).then(resolve) + } + return builder + }) as unknown as DatabaseClient + + let repository: UserSubscriptionRepository + + beforeEach(() => { + sql = [] + repository = new UserSubscriptionRepository({} as DatabaseClient) + }) + + describe('.findByPubkey', () => { + it('returns undefined when the pubkey has no subscription', async () => { + expect(await repository.findByPubkey(pubkeyHex, clientReturning([]))).to.equal(undefined) + }) + + it('maps the row to a UserSubscription and queries by the pubkey bytes', async () => { + const subscription = await repository.findByPubkey(pubkeyHex, clientReturning([row])) + + expect(subscription).to.deep.equal({ + pubkey: pubkeyHex, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: start, + currentPeriodEnd: end, + graceEndsAt: null, + cancelAtPeriodEnd: false, + createdAt: start, + updatedAt: start, + }) + expect(sql[0]).to.include('from "user_subscriptions"') + expect(sql[0]).to.include(`"pubkey" = X'${pubkeyHex}'`) + }) + + it('keeps a never-ending subscription as a null period end', async () => { + const subscription = await repository.findByPubkey( + pubkeyHex, + clientReturning([{ ...row, plan_id: 'grandfathered', current_period_end: null }]), + ) + + expect(subscription?.currentPeriodEnd).to.equal(null) + }) + }) + + describe('.upsert', () => { + const subscription = { + pubkey: pubkeyHex, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: start, + currentPeriodEnd: end, + graceEndsAt: null, + cancelAtPeriodEnd: false, + } + + it('inserts or updates the row for the pubkey', async () => { + await repository.upsert(subscription, clientReturning({ rowCount: 1 })) + + expect(sql[0]).to.include('insert into "user_subscriptions"') + expect(sql[0]).to.include(`X'${pubkeyHex}'`) + expect(sql[0]).to.include('on conflict ("pubkey") do update set') + }) + + it('updates the subscription but never the pubkey or created_at', async () => { + await repository.upsert(subscription, clientReturning({ rowCount: 1 })) + + const update = sql[0].split('do update set')[1] + for (const column of [ + 'plan_id', + 'status', + 'current_period_start', + 'current_period_end', + 'grace_ends_at', + 'cancel_at_period_end', + 'updated_at', + ]) { + expect(update, column).to.include(`"${column}" = excluded."${column}"`) + } + expect(update).to.not.include('"pubkey"') + expect(update).to.not.include('"created_at"') + }) + + it('returns the number of affected rows', async () => { + expect(await repository.upsert(subscription, clientReturning({ rowCount: 1 }))).to.equal(1) + }) + }) + + describe('.updateIfCurrent', () => { + const current = { + pubkey: pubkeyHex, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: start, + currentPeriodEnd: end, + graceEndsAt: null, + cancelAtPeriodEnd: false, + createdAt: start, + updatedAt: start, + } + + it('updates only the row that still matches the state that was read', async () => { + await repository.updateIfCurrent(current, { status: SubscriptionStatus.RENEWAL_PENDING }, clientReturning(1)) + + expect(sql[0]).to.include('update "user_subscriptions" set') + expect(sql[0]).to.include(`"status" = 'renewal_pending'`) + expect(sql[0]).to.include(`"pubkey" = X'${pubkeyHex}'`) + expect(sql[0]).to.include(`"status" = 'active'`) + expect(sql[0]).to.include(`"plan_id" = 'basic'`) + expect(sql[0]).to.include(`current_period_end IS NOT DISTINCT FROM ${printed(end)}`) + expect(sql[0]).to.include('grace_ends_at IS NOT DISTINCT FROM NULL') + }) + + it('matches a grace end that was set', async () => { + const graceEndsAt = new Date('2026-02-03T00:00:00.000Z') + + await repository.updateIfCurrent( + { ...current, status: SubscriptionStatus.PAST_DUE, graceEndsAt }, + { status: SubscriptionStatus.EXPIRED, graceEndsAt: null }, + clientReturning(1), + ) + + expect(sql[0]).to.include(`"status" = 'past_due'`) + expect(sql[0]).to.include(`grace_ends_at IS NOT DISTINCT FROM ${printed(graceEndsAt)}`) + expect(sql[0]).to.include('"grace_ends_at" = NULL') + }) + + it('never changes the plan or the period, and leaves untouched fields out', async () => { + await repository.updateIfCurrent(current, { status: SubscriptionStatus.EXPIRED }, clientReturning(1)) + + expect(sql[0]).to.not.match(/set[^w]*"plan_id"/) + expect(sql[0]).to.not.match(/set[^w]*"current_period_end"/) + expect(sql[0]).to.not.match(/set[^w]*"grace_ends_at"/) + expect(sql[0]).to.include('"updated_at" =') + }) + + it('reports whether a row was updated', async () => { + expect( + await repository.updateIfCurrent(current, { status: SubscriptionStatus.EXPIRED }, clientReturning(1)), + ).to.equal(true) + expect( + await repository.updateIfCurrent(current, { status: SubscriptionStatus.EXPIRED }, clientReturning(0)), + ).to.equal(false) + }) + }) + + describe('.findDueForRenewal', () => { + it('selects active subscriptions that are not cancelling and end by the cut-off', async () => { + const subscriptions = await repository.findDueForRenewal(end, undefined, undefined, clientReturning([row])) + + expect(subscriptions).to.have.length(1) + expect(sql[0]).to.include(`"status" = 'active'`) + expect(sql[0]).to.include('"cancel_at_period_end" = false') + expect(sql[0]).to.include('"current_period_end" is not null') + expect(sql[0]).to.include(`"current_period_end" <= ${printed(end)}`) + expect(sql[0]).to.include('order by "current_period_end" asc') + expect(sql[0]).to.include('limit 10') + }) + + it('also selects renewal-pending subscriptions whose renewal invoice is no longer payable', async () => { + await repository.findDueForRenewal(end, undefined, undefined, clientReturning([])) + + expect(sql[0]).to.include(`"status" = 'renewal_pending'`) + expect(sql[0]).to.include('not exists (select "invoices"."id" from "invoices"') + expect(sql[0]).to.include('invoices.pubkey = user_subscriptions.pubkey') + expect(sql[0]).to.include('invoices.plan_id = user_subscriptions.plan_id') + expect(sql[0]).to.include(`"invoices"."status" = 'pending'`) + expect(sql[0]).to.include('"invoices"."expires_at" is null or "invoices"."expires_at" >') + }) + + it('honours the limit and the offset', async () => { + await repository.findDueForRenewal(end, 3, 6, clientReturning([])) + + expect(sql[0]).to.include('limit 3') + expect(sql[0]).to.include('offset 6') + }) + }) + + describe('.findPeriodEnded', () => { + it('selects active and renewal-pending subscriptions whose period has ended', async () => { + await repository.findPeriodEnded(end, undefined, undefined, clientReturning([row])) + + expect(sql[0]).to.include(`"status" in ('active', 'renewal_pending')`) + expect(sql[0]).to.include('"current_period_end" is not null') + expect(sql[0]).to.include(`"current_period_end" <= ${printed(end)}`) + expect(sql[0]).to.include('limit 10') + }) + + it('honours the offset', async () => { + await repository.findPeriodEnded(end, 10, 4, clientReturning([])) + + expect(sql[0]).to.include('offset 4') + }) + }) + + describe('.findGraceEnded', () => { + it('selects past-due subscriptions whose grace period has ended', async () => { + await repository.findGraceEnded( + end, + undefined, + undefined, + clientReturning([{ ...row, status: SubscriptionStatus.PAST_DUE }]), + ) + + expect(sql[0]).to.include(`"status" = 'past_due'`) + expect(sql[0]).to.include('"grace_ends_at" is not null') + expect(sql[0]).to.include(`"grace_ends_at" <= ${printed(end)}`) + expect(sql[0]).to.include('order by "grace_ends_at" asc') + }) + + it('honours the offset', async () => { + await repository.findGraceEnded(end, 10, 2, clientReturning([])) + + expect(sql[0]).to.include('offset 2') + }) + }) +}) diff --git a/test/unit/routes/admin-settings.spec.ts b/test/unit/routes/admin-settings.spec.ts index 2d4f1f085..fdf483cd1 100644 --- a/test/unit/routes/admin-settings.spec.ts +++ b/test/unit/routes/admin-settings.spec.ts @@ -177,9 +177,14 @@ describe('admin settings API', () => { const passwordHash = hashAdminPassword('settings-password') saveSettings({ ...loadDefaults(), - admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, - mirroring: { - static: [{ address: 'wss://mirror.example', secret: 'mirror-secret-value' }], + admin: { + enabled: true, + passwordHash, + sessionTtlSeconds: 3600, + notifications: { + ...loadDefaults().admin?.notifications, + targets: [{ id: 'ops-telegram', type: 'telegram', enabled: true, botToken: 'telegram-bot-token', chatId: '1' }], + }, }, }) const baseUrl = await startServer({ @@ -198,7 +203,7 @@ describe('admin settings API', () => { expect(settingsResponse.status).to.equal(200) expect(settingsResponse.data.settings.admin.passwordHash).to.equal('***') - expect(settingsResponse.data.settings.mirroring.static[0].secret).to.equal('***') + expect(settingsResponse.data.settings.admin.notifications.targets[0].botToken).to.equal('***') expect(settingsResponse.data.settings.info.name).to.be.a('string') expect(schemaResponse.status).to.equal(200) diff --git a/test/unit/routes/admin.spec.ts b/test/unit/routes/admin.spec.ts index de96ae294..82ba4f77b 100644 --- a/test/unit/routes/admin.spec.ts +++ b/test/unit/routes/admin.spec.ts @@ -204,14 +204,18 @@ describe('admin router', () => { const healthResponse = await axios.get(`${baseUrl}/health`, { validateStatus: () => true }) const metricsResponse = await axios.get(`${baseUrl}/metrics`, { validateStatus: () => true }) const networkHealthResponse = await axios.get(`${baseUrl}/network-health`, { validateStatus: () => true }) + const networkHealthHistoryResponse = await axios.get(`${baseUrl}/network-health/history`, { + validateStatus: () => true, + }) const notificationsResponse = await axios.get(`${baseUrl}/notifications`, { validateStatus: () => true }) expect(sessionResponse.status).to.equal(401) expect(healthResponse.status).to.equal(401) expect(metricsResponse.status).to.equal(401) expect(networkHealthResponse.status).to.equal(401) + expect(networkHealthHistoryResponse.status).to.equal(401) expect(notificationsResponse.status).to.equal(401) - expect(rateLimiterMiddlewareStub.callCount).to.equal(5) + expect(rateLimiterMiddlewareStub.callCount).to.equal(6) }) it('authenticates a protected route with a signed NIP-98 event', async () => { diff --git a/test/unit/schemas/admin-moderation-schema.spec.ts b/test/unit/schemas/admin-moderation-schema.spec.ts new file mode 100644 index 000000000..db38cbc0b --- /dev/null +++ b/test/unit/schemas/admin-moderation-schema.spec.ts @@ -0,0 +1,21 @@ +import { expect } from 'chai' + +import { adminModerationLimitsPatchSchema } from '../../../src/schemas/admin-moderation-schema' + +describe('admin-moderation-schema', () => { + it('accepts the same kinds event admission accepts', () => { + const result = adminModerationLimitsPatchSchema.safeParse({ + kind: { + blacklist: [65536, [65536, 65536]], + whitelist: [0, [0, 65535]], + }, + }) + + expect(result.success).to.equal(true) + }) + + it('rejects negative kinds and inverted ranges', () => { + expect(adminModerationLimitsPatchSchema.safeParse({ kind: { blacklist: [-1] } }).success).to.equal(false) + expect(adminModerationLimitsPatchSchema.safeParse({ kind: { blacklist: [[10, 1]] } }).success).to.equal(false) + }) +}) diff --git a/test/unit/schemas/message-schema.spec.ts b/test/unit/schemas/message-schema.spec.ts index 562e5b906..f60c45271 100644 --- a/test/unit/schemas/message-schema.spec.ts +++ b/test/unit/schemas/message-schema.spec.ts @@ -206,3 +206,84 @@ describe('NIP-01', () => { }) }) }) + +describe('NIP-77', () => { + const valid = { + open: ['NEG-OPEN', 'sync1', { kinds: [1], since: 1000 }, '61000002'], + msg: ['NEG-MSG', 'sync1', '6100000200'], + close: ['NEG-CLOSE', 'sync1'], + } + const check = (message: unknown) => validateSchema(messageSchema)(message as any) + + describe('NEG-OPEN', () => { + it('returns same message if valid', () => { + const result = check(valid.open) + + expect(result.error).to.be.undefined + expect(result).to.have.deep.property('value', valid.open) + }) + + it('accepts uppercase hex', () => { + expect(check(['NEG-OPEN', 'sync1', {}, 'ABCDEF01']).error).to.be.undefined + }) + + for (const [name, mutate] of [ + ['subscription id is missing', (m: any[]) => m.splice(1, 1)], + ['subscription id is empty', (m: any[]) => (m[1] = '')], + ['subscription id is longer than 256 characters', (m: any[]) => (m[1] = 'a'.repeat(257))], + ['filter is missing', (m: any[]) => m.splice(2, 1)], + ['filter is an array of filters', (m: any[]) => (m[2] = [{}])], + ['filter is not an object', (m: any[]) => (m[2] = null)], + ['initial message is missing', (m: any[]) => m.splice(3, 1)], + ['initial message is empty', (m: any[]) => (m[3] = '')], + ['initial message is not hex', (m: any[]) => (m[3] = 'zz')], + ['initial message has an odd number of hex digits', (m: any[]) => (m[3] = '610')], + ['there are extra elements', (m: any[]) => m.push('extra')], + ] as [string, (m: any[]) => void][]) { + it(`returns error if ${name}`, () => { + const message = JSON.parse(JSON.stringify(valid.open)) + mutate(message) + + expect(check(message)).to.have.property('error').that.is.not.undefined + }) + } + }) + + describe('NEG-MSG', () => { + it('returns same message if valid', () => { + const result = check(valid.msg) + + expect(result.error).to.be.undefined + expect(result).to.have.deep.property('value', valid.msg) + }) + + for (const [name, message] of [ + ['subscription id is empty', ['NEG-MSG', '', '6100']], + ['payload is not hex', ['NEG-MSG', 'sync1', 'not hex']], + ['payload is empty', ['NEG-MSG', 'sync1', '']], + ['payload is missing', ['NEG-MSG', 'sync1']], + ] as [string, unknown[]][]) { + it(`returns error if ${name}`, () => { + expect(check(message)).to.have.property('error').that.is.not.undefined + }) + } + }) + + describe('NEG-CLOSE', () => { + it('returns same message if valid', () => { + const result = check(valid.close) + + expect(result.error).to.be.undefined + expect(result).to.have.deep.property('value', valid.close) + }) + + it('returns error if subscription id is missing or empty', () => { + expect(check(['NEG-CLOSE'])).to.have.property('error').that.is.not.undefined + expect(check(['NEG-CLOSE', ''])).to.have.property('error').that.is.not.undefined + }) + }) + + it('does not accept NEG-ERR from a client', () => { + expect(check(['NEG-ERR', 'sync1', 'blocked: no'])).to.have.property('error').that.is.not.undefined + }) +}) diff --git a/test/unit/scripts/deploy-with-retry.spec.ts b/test/unit/scripts/deploy-with-retry.spec.ts new file mode 100644 index 000000000..a106e4741 --- /dev/null +++ b/test/unit/scripts/deploy-with-retry.spec.ts @@ -0,0 +1,268 @@ +import { expect } from 'chai' +import { spawnSync } from 'child_process' +import fs from 'fs' +import os from 'os' +import path from 'path' + +const projectRoot = process.cwd() +const scriptPath = path.join(projectRoot, '.github', 'scripts', 'deploy-with-retry.sh') + +/** + * Each entry describes what the stub `wrangler` does on that attempt: print + * `stderr` and exit with `code`, or `sleep` for a number of seconds first. An + * attempt beyond the list succeeds. The stub appends its arguments to a file so + * the test can check what the caller actually invoked. + */ +type Attempt = { stderr?: string; code?: number; sleep?: number } + +const writeStub = (dir: string, attempts: Attempt[]): string => { + const binDir = path.join(dir, 'bin') + fs.mkdirSync(binDir, { recursive: true }) + const cases = attempts + .map((attempt, index) => { + const body = [ + attempt.sleep ? `sleep ${attempt.sleep}` : '', + attempt.stderr ? `printf '%s\\n' ${JSON.stringify(attempt.stderr)} >&2` : '', + // A sleep-only attempt is expected to be killed by the attempt timeout, + // so it never reaches its own exit. + `exit ${attempt.code ?? 0}`, + ] + .filter(Boolean) + .join('; ') + return ` ${index + 1}) ${body};;` + }) + .join('\n') + const stub = `#!/usr/bin/env bash +# One line per attempt, one field per argument: "$*" would join the arguments +# and hide an unquoted "$@" in the script under test. +for arg in "$@"; do printf '%s' "$arg" >> "$STUB_ARGS"; done +printf '\\n' >> "$STUB_ARGS" +# BSD wc -l pads its count with leading spaces, which would make every case +# branch miss; the arithmetic expansion normalises it to a plain integer. +attempt=$(( $(wc -l < "$STUB_ARGS") )) +case "$attempt" in +${cases} + *) echo "deployed"; exit 0;; +esac +` + + fs.writeFileSync(path.join(binDir, 'wrangler'), stub, { mode: 0o755 }) + return binDir +} + +const ARG_SEPARATOR = '' + +const parseAttempts = (recorded: string): string[][] => { + const trimmed = recorded.trim() + if (trimmed === '') { + return [] + } + return trimmed.split('\n').map((line) => { + const fields = line.split(ARG_SEPARATOR) + // Every argument is followed by the separator, so the split leaves a + // trailing empty field. + return fields[fields.length - 1] === '' ? fields.slice(0, -1) : fields + }) +} + +type Run = { + status: number | null + stdout: string + stderr: string + attempts: number + invocations: string[][] + /** Arguments of the first attempt, the one every test's expectation is about. */ + args: string[] +} + +type RunOptions = { env?: NodeJS.ProcessEnv; pathPrefixes?: string[]; systemPath?: boolean } + +const runScript = (dir: string, attempts: Attempt[], args: string[], options: RunOptions = {}): Run => { + const binDir = writeStub(dir, attempts) + const counter = path.join(dir, 'invocations.txt') + fs.writeFileSync(counter, '') + const searchPath = [ + ...(options.pathPrefixes ?? []), + binDir, + ...(options.systemPath === false ? [] : [process.env.PATH]), + ].join(':') + const result = spawnSync('bash', [scriptPath, ...args], { + cwd: projectRoot, + env: { + ...process.env, + ...options.env, + STUB_ARGS: counter, + PATH: searchPath, + // The production defaults wait 10 s then 30 s; the tests assert the + // retry happened, not how long it took. + DEPLOY_BACKOFFS: '0 0', + }, + encoding: 'utf-8', + timeout: 30_000, + }) + const invocations = parseAttempts(fs.readFileSync(counter, 'utf-8')) + + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + attempts: invocations.length, + invocations, + args: invocations[0] ?? [], + } +} + +const WRANGLER_ARGS = [ + 'wrangler', + 'deploy', + '--config', + 'wrangler.router.jsonc', + '--var', + 'CANONICAL_HOST:nostream.dev', +] + +const deploy = (dir: string, attempts: Attempt[], options: RunOptions = {}): Run => + runScript(dir, attempts, WRANGLER_ARGS, options) + +/** Resolve a command the way a shell would, for building a restricted PATH. */ +const resolveOnPath = (command: string): string | undefined => { + for (const entry of (process.env.PATH ?? '').split(':')) { + const candidate = path.join(entry, command) + if (fs.existsSync(candidate)) { + return candidate + } + } + return undefined +} + +// The script also warns when coreutils is absent (stock macOS), and that line is +// present on every run there. Assertions about retries must name the retry line, +// or they would fail on a laptop and pass in CI. +const RETRY_WARNING = /::warning::wrangler deploy attempt/g + +describe('deploy-with-retry.sh', () => { + let dir: string + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-deploy-retry-')) + }) + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }) + }) + + it('retries a Cloudflare 10013 failure and names the attempt and reason', () => { + const result = deploy(dir, [ + { + stderr: + 'A request to the Cloudflare API (/accounts/x/workers/scripts/y/assets-upload-session) failed.\n An unknown error has occurred. [code: 10013]', + code: 1, + }, + ]) + + expect(result.status, result.stderr).to.equal(0) + expect(result.attempts).to.equal(2) + expect(result.stdout).to.match(/::warning::wrangler deploy attempt 2\/3.*10013/) + }) + + it('does not retry an authentication failure', () => { + const result = deploy(dir, [ + { stderr: 'Authentication error [code: 10000]\n Invalid API Token (HTTP 401)', code: 1 }, + ]) + + expect(result.status).to.not.equal(0) + expect(result.attempts).to.equal(1) + expect(result.stdout).to.not.match(RETRY_WARNING) + expect(result.stderr).to.include('::error::') + }) + + it('does not retry a wrangler validation error', () => { + const result = deploy(dir, [ + { stderr: '✘ [ERROR] Missing entry-point: The entry-point file at "dist/_worker.js" was not found.', code: 1 }, + ]) + + expect(result.status).to.not.equal(0) + expect(result.attempts).to.equal(1) + expect(result.stdout).to.not.match(RETRY_WARNING) + }) + + it('retries an HTTP 5xx from the Cloudflare API', () => { + const result = deploy(dir, [{ stderr: 'A request to the Cloudflare API failed: HTTP 502', code: 1 }]) + + expect(result.status, result.stderr).to.equal(0) + expect(result.attempts).to.equal(2) + expect(result.stdout).to.match(/::warning::.*HTTP 502/) + }) + + it('retries a connection reset', () => { + const result = deploy(dir, [{ stderr: 'Error: read ECONNRESET: connection reset by peer', code: 1 }]) + + expect(result.status, result.stderr).to.equal(0) + expect(result.attempts).to.equal(2) + expect(result.stdout).to.match(/::warning::.*connection reset/) + }) + + it('gives up after the last attempt and keeps the last error on stderr', () => { + const failing: Attempt = { stderr: 'An unknown error has occurred. [code: 10013]', code: 1 } + const result = deploy(dir, [failing, failing, failing]) + + expect(result.status).to.not.equal(0) + expect(result.attempts).to.equal(3) + expect(result.stderr).to.include('::error::deploy failed after 3 attempts') + expect(result.stderr).to.include('10013') + // One warning per retry, none for the first attempt. + expect(result.stdout.match(RETRY_WARNING)).to.have.length(2) + }) + + it('passes the wrangler arguments through unchanged, one argument at a time', () => { + const result = deploy(dir, []) + + expect(result.status, result.stderr).to.equal(0) + expect(result.attempts).to.equal(1) + expect(result.args).to.deep.equal(['deploy', '--config', 'wrangler.router.jsonc', '--var', 'CANONICAL_HOST:nostream.dev']) + }) + + it('keeps an argument that contains spaces as one argument', () => { + const args = ['wrangler', 'deploy', '--var', 'SITE_TITLE:Oh My Relay'] + const result = runScript(dir, [], args) + + expect(result.status, result.stderr).to.equal(0) + // Unquoted `$@` in the script would split this into four arguments. + expect(result.args).to.deep.equal(['deploy', '--var', 'SITE_TITLE:Oh My Relay']) + }) + + it('still deploys when no timeout command is installed (stock macOS)', () => { + // A PATH with the tools the script needs and, deliberately, neither + // `timeout` nor `gtimeout`. The script must degrade to an unbounded + // attempt and say so, not exit 127 before the deploy runs. + const restricted = path.join(dir, 'restricted-bin') + fs.mkdirSync(restricted, { recursive: true }) + for (const tool of ['bash', 'cat', 'grep', 'head', 'mktemp', 'rm', 'sleep', 'tail', 'wc']) { + const resolved = resolveOnPath(tool) + if (!resolved) { + throw new Error(`${tool} not found on PATH; cannot build the restricted environment`) + } + fs.symlinkSync(resolved, path.join(restricted, tool)) + } + + const result = deploy(dir, [], { pathPrefixes: [restricted], systemPath: false }) + + expect(result.status, result.stderr).to.equal(0) + expect(result.attempts).to.equal(1) + expect(result.stdout).to.match(/::warning::no 'timeout' or 'gtimeout' on PATH/) + }) + + it('retries an attempt that exceeds the per-attempt timeout', function () { + if (!resolveOnPath('timeout') && !resolveOnPath('gtimeout')) { + // Only reached on a host without coreutils, where the script has no bound + // to exceed in the first place. + this.skip() + } + this.timeout(20_000) + const result = deploy(dir, [{ sleep: 30 }], { env: { DEPLOY_ATTEMPT_TIMEOUT: '1' } }) + + expect(result.status, result.stderr).to.equal(0) + expect(result.attempts).to.equal(2) + expect(result.stdout).to.match(/::warning::.*timed out after 1s/) + }) +}) diff --git a/test/unit/services/maintenance-service.spec.ts b/test/unit/services/maintenance-service.spec.ts index 33b9319ea..fdf3f0621 100644 --- a/test/unit/services/maintenance-service.spec.ts +++ b/test/unit/services/maintenance-service.spec.ts @@ -111,5 +111,77 @@ describe('MaintenanceService', () => { expect(eventRepository.deleteExpiredAndRetained).to.have.been.calledOnce }) + describe('plan retention', () => { + const plan = (id: string, limits?: unknown) => ({ + id, + amount: 1000000n, + intervalSeconds: 2592000, + graceSeconds: 259200, + ...(limits ? { limits } : {}), + }) + + const withPlans = (plans: unknown[], payments: Record = {}) => { + settings.returns({ + payments: { enabled: true, subscriptionPlans: plans, ...payments }, + limits: { event: { retention: { maxDays: 30 } } }, + } as any) + eventRepository.deleteExpiredAndRetained.resolves({ deleted: 0, expired: 0, retained: 0 }) + } + + it('hands the retention of each plan that has one to the purge', async () => { + withPlans([ + plan('basic', { retentionMaxDays: 90 }), + plan('free'), + plan('vip', { retentionMaxDays: 365, rateLimits: [] }), + ]) + + await service.clearOldEvents() + + expect(eventRepository.deleteExpiredAndRetained).to.have.been.calledOnceWithExactly({ + maxDays: 30, + planRetention: [ + { planId: 'basic', maxDays: 90 }, + { planId: 'vip', maxDays: 365 }, + ], + kindWhitelist: undefined, + pubkeyWhitelist: undefined, + }) + }) + + it('purges as before when no plan has a retention', async () => { + withPlans([plan('basic'), plan('vip', { rateLimits: [] })]) + + await service.clearOldEvents() + + expect(eventRepository.deleteExpiredAndRetained.firstCall.args[0]).to.not.have.property('planRetention') + }) + + it('ignores plan retentions while payments are off', async () => { + withPlans([plan('basic', { retentionMaxDays: 90 })], { enabled: false }) + + await service.clearOldEvents() + + expect(eventRepository.deleteExpiredAndRetained.firstCall.args[0]).to.not.have.property('planRetention') + }) + + it('ignores plan retentions when no plan is configured', async () => { + withPlans([]) + + await service.clearOldEvents() + + expect(eventRepository.deleteExpiredAndRetained.firstCall.args[0]).to.not.have.property('planRetention') + }) + + it('does not purge when the global retention is off, however long the plans keep events', async () => { + settings.returns({ + payments: { enabled: true, subscriptionPlans: [plan('basic', { retentionMaxDays: 90 })] }, + limits: { event: { retention: { maxDays: 0 } } }, + } as any) + + await service.clearOldEvents() + + expect(eventRepository.deleteExpiredAndRetained).not.to.have.been.called + }) + }) }) }) diff --git a/test/unit/services/notification-channels.spec.ts b/test/unit/services/notification-channels.spec.ts index 3213f6296..de25af2e0 100644 --- a/test/unit/services/notification-channels.spec.ts +++ b/test/unit/services/notification-channels.spec.ts @@ -1,10 +1,13 @@ -import axios from 'axios' import chai from 'chai' import Sinon from 'sinon' import sinonChai from 'sinon-chai' -import { OperatorNotificationEventType } from '../../../src/@types/operator-notifications' -import { deliverToTarget, validateTargetConfig } from '../../../src/services/notification-channels' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const axios = require('axios') +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { OperatorNotificationEventType } = require('../../../src/@types/operator-notifications') +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { deliverToTarget, validateTargetConfig } = require('../../../src/services/notification-channels') chai.use(sinonChai) @@ -35,7 +38,31 @@ describe('notification-channels', () => { envelope, ) - expect(axios.post).to.have.been.calledOnceWith('https://example.com/hook', envelope, Sinon.match.object) + const body = JSON.stringify(envelope) + expect(axios.post).to.have.been.calledOnceWith( + 'https://example.com/hook', + body, + Sinon.match({ + headers: { 'content-type': 'application/json' }, + }), + ) + }) + + it('signs HTTP webhook payloads when a secret is configured', async () => { + await deliverToTarget( + { + id: 'http-1', + type: 'http', + enabled: true, + url: 'https://example.com/hook', + secret: 'test-secret', + }, + envelope, + ) + + expect(axios.post).to.have.been.calledOnce + const [, , config] = axios.post.getCall(0).args + expect(config.headers['x-nostream-signature']).to.match(/^sha256=[a-f0-9]{64}$/) }) it('delivers Slack webhook payloads', async () => { diff --git a/test/unit/services/payments-service.spec.ts b/test/unit/services/payments-service.spec.ts index c32fbc6fb..2fe8ab6dd 100644 --- a/test/unit/services/payments-service.spec.ts +++ b/test/unit/services/payments-service.spec.ts @@ -212,6 +212,15 @@ describe('PaymentsService', () => { expect(result.pubkey).to.equal('pubkey1234') }) + it('stores the plan on the invoice and returns it', async () => { + settings.returns({ payments: { invoiceExpirySeconds: 3600 } }) + + const result = await service.createInvoice('pubkey1234', 1000n, 'test', 'basic') + + expect(invoiceRepository.upsert.firstCall.args[0]).to.include({ planId: 'basic' }) + expect(result.planId).to.equal('basic') + }) + it('gives the invoice an expiry when the processor does not report one', async () => { // Without this the row can never be retired and stays pending forever. settings.returns({ payments: { invoiceExpirySeconds: 3600 } }) @@ -488,6 +497,172 @@ describe('PaymentsService', () => { }) + describe('subscription plans', () => { + const DAY = 86_400_000 + const plan = { id: 'basic', amount: 5_000_000n, intervalSeconds: 2_592_000, graceSeconds: 259_200 } + let userSubscriptionRepository: any + + const plansSettings = (plans: any[] = [plan], admission: any[] = [{ enabled: true, amount: 1000n }]) => ({ + payments: { enabled: true, feeSchedules: { admission }, subscriptionPlans: plans }, + }) + + beforeEach(() => { + userSubscriptionRepository = { + findByPubkey: sandbox.stub().resolves(undefined), + upsert: sandbox.stub().resolves(1), + } + invoiceRepository.findById = sandbox.stub().resolves(stubInvoice({ planId: 'basic' })) + settings.returns(plansSettings()) + service = new PaymentsService( + dbClient, + paymentsProcessor, + userRepository, + invoiceRepository, + eventRepository, + settings, + notificationOutboxRepository, + userSubscriptionRepository, + ) + }) + + it('starts a subscription for the plan on the stored invoice and admits the user', async () => { + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n })) + + expect(invoiceRepository.findById).to.have.been.calledOnceWithExactly('invoice-id', mockTrx) + expect(userSubscriptionRepository.upsert).to.have.been.calledOnce + const [subscription, client] = userSubscriptionRepository.upsert.firstCall.args + expect(subscription).to.include({ pubkey: 'pubkey1234', planId: 'basic', status: 'active' }) + expect(subscription.currentPeriodEnd.getTime() - subscription.currentPeriodStart.getTime()).to.equal( + 30 * DAY, + ) + expect(client).to.equal(mockTrx) + expect(userRepository.admitUser).to.have.been.calledOnce + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('prefers the plan on the invoice it was given over the stored one', async () => { + invoiceRepository.findById.resolves(stubInvoice({ planId: null })) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n, planId: 'basic' })) + + expect(userSubscriptionRepository.upsert).to.have.been.calledOnce + }) + + it('extends a running subscription from its period end', async () => { + const periodEnd = new Date(Date.now() + 5 * DAY) + userSubscriptionRepository.findByPubkey.resolves({ + pubkey: 'pubkey1234', + planId: 'basic', + status: 'active', + currentPeriodStart: new Date(Date.now() - 25 * DAY), + currentPeriodEnd: periodEnd, + graceEndsAt: null, + cancelAtPeriodEnd: false, + }) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n })) + + const [subscription] = userSubscriptionRepository.upsert.firstCall.args + expect(subscription.currentPeriodStart).to.deep.equal(periodEnd) + expect(subscription.currentPeriodEnd).to.deep.equal(new Date(periodEnd.getTime() + 30 * DAY)) + }) + + it('does not extend the subscription again for a repeated callback of an invoice that was already confirmed', async () => { + invoiceRepository.findById.resolves(stubInvoice({ planId: 'basic', confirmedAt: new Date() })) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n })) + + expect(userSubscriptionRepository.findByPubkey).not.to.have.been.called + expect(userSubscriptionRepository.upsert).not.to.have.been.called + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('reads the stored invoice before confirming it, so a first confirmation is told from a repeat', async () => { + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n })) + + expect(invoiceRepository.findById).to.have.been.calledBefore(invoiceRepository.confirmInvoice) + }) + + it('takes the unit from the stored invoice when the callback does not report one', async () => { + // OpenNode reports the amount in sats without a unit. + invoiceRepository.findById.resolves(stubInvoice({ planId: 'basic', unit: InvoiceUnit.SATS })) + + await service.confirmInvoice(makeCompletedInvoice({ unit: undefined, amountPaid: 5_000n } as any)) + + expect(userSubscriptionRepository.upsert).to.have.been.calledOnce + }) + + it('still rejects an underpayment when the unit comes from the stored invoice', async () => { + invoiceRepository.findById.resolves(stubInvoice({ planId: 'basic', unit: InvoiceUnit.SATS })) + + await service.confirmInvoice(makeCompletedInvoice({ unit: undefined, amountPaid: 4_999n } as any)) + + expect(userSubscriptionRepository.upsert).not.to.have.been.called + }) + + it('converts SATS to msats before comparing against the plan price', async () => { + await service.confirmInvoice(makeCompletedInvoice({ unit: InvoiceUnit.SATS, amountPaid: 5_000n })) + + expect(userSubscriptionRepository.upsert).to.have.been.calledOnce + }) + + it('does not start a subscription when the paid amount is below the plan price', async () => { + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 4_999_999n })) + + expect(userSubscriptionRepository.upsert).not.to.have.been.called + expect(userRepository.admitUser).not.to.have.been.called + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('does not fall back to the admission fee when a plan invoice is underpaid', async () => { + settings.returns(plansSettings([plan], [{ enabled: true, amount: 1000n }])) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 4_000_000n })) + + expect(userRepository.admitUser).not.to.have.been.called + }) + + it('treats an invoice without a plan as an admission payment', async () => { + invoiceRepository.findById.resolves(stubInvoice({ planId: null })) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 2000n })) + + expect(userSubscriptionRepository.upsert).not.to.have.been.called + expect(userRepository.admitUser).to.have.been.calledOnce + }) + + it('treats an invoice whose plan was removed from the settings as an admission payment', async () => { + invoiceRepository.findById.resolves(stubInvoice({ planId: 'retired' })) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 2000n })) + + expect(userSubscriptionRepository.upsert).not.to.have.been.called + expect(userRepository.admitUser).to.have.been.calledOnce + }) + + it('does not read the invoice or subscriptions while no plan is configured', async () => { + settings.returns(plansSettings([])) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 2000n })) + + expect(invoiceRepository.findById).not.to.have.been.called + expect(userSubscriptionRepository.findByPubkey).not.to.have.been.called + expect(userRepository.admitUser).to.have.been.calledOnce + }) + + it('rolls back when the subscription cannot be saved, so the user is not admitted for free', async () => { + userSubscriptionRepository.upsert.rejects(new Error('db error')) + + await expect(service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n }))).to.be.rejectedWith( + 'db error', + ) + + expect(mockTrx.rollback).to.have.been.calledOnce + expect(mockTrx.commit).not.to.have.been.called + expect(userRepository.admitUser).not.to.have.been.called + }) + }) + it('rolls back the transaction and re-throws on error', async () => { settings.returns(makeSettings([])) invoiceRepository.confirmInvoice.rejects(new Error('db error')) @@ -500,6 +675,66 @@ describe('PaymentsService', () => { }) + describe('sendRenewalNotification', () => { + const renewalInvoice = () => + stubInvoice({ + pubkey: 'subscriber1234', + bolt11: 'lnbc50000n1...', + amountRequested: 5_000_000n, + unit: InvoiceUnit.MSATS, + expiresAt: new Date('2030-01-01T00:00:00Z'), + planId: 'basic', + }) + + beforeEach(() => { + settings.returns({ info: { relay_url: 'wss://relay.example.com' } }) + }) + + it('tells the subscriber what is due, with the invoice to pay, until the invoice expires', async () => { + await service.sendRenewalNotification(renewalInvoice()) + + const unsigned = (eventUtils.identifyEvent as Sinon.SinonStub).firstCall.args[0] + expect(unsigned.kind).to.equal(402) + expect(unsigned.pubkey).to.equal('fakepubkey') + expect(unsigned.content).to.equal('Subscription renewal due: 5000 sats') + expect(unsigned.tags).to.deep.include(['p', 'subscriber1234']) + expect(unsigned.tags).to.deep.include(['bolt11', 'lnbc50000n1...']) + expect(unsigned.tags).to.deep.include(['expiration', '1893456000']) + }) + + it('keeps the unit when it is not msats', async () => { + await service.sendRenewalNotification({ ...renewalInvoice(), unit: InvoiceUnit.SATS, amountRequested: 5000n }) + + expect((eventUtils.identifyEvent as Sinon.SinonStub).firstCall.args[0].content).to.equal( + 'Subscription renewal due: 5000 sats', + ) + }) + + it('expires the notice a day out when the invoice has no expiry', async () => { + const before = Math.floor(Date.now() / 1000) + + await service.sendRenewalNotification({ ...renewalInvoice(), expiresAt: null }) + + const expiration = (eventUtils.identifyEvent as Sinon.SinonStub).firstCall.args[0].tags.find( + (tag: string[]) => tag[0] === 'expiration', + ) + expect(Number(expiration[1])).to.be.within(before + 86_399, before + 86_402) + }) + + it('persists and broadcasts the signed event', async () => { + await service.sendRenewalNotification(renewalInvoice()) + + expect(eventRepository.create).to.have.been.calledOnce + expect(eventUtils.broadcastEvent).to.have.been.calledOnce + }) + + it('does not throw when the pipeline fails', async () => { + eventRepository.create.rejects(new Error('db down')) + + await expect(service.sendRenewalNotification(renewalInvoice())).to.be.fulfilled + }) + }) + describe('sendInvoiceUpdateNotification', () => { beforeEach(() => { settings.returns({ info: { relay_url: 'wss://relay.example.com' } }) diff --git a/test/unit/services/subscription-service.spec.ts b/test/unit/services/subscription-service.spec.ts new file mode 100644 index 000000000..bd091403d --- /dev/null +++ b/test/unit/services/subscription-service.spec.ts @@ -0,0 +1,348 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +import { RENEWAL_LEAD_SECONDS } from '../../../src/utils/subscription' +import { Settings } from '../../../src/@types/settings' +import { SubscriptionService } from '../../../src/services/subscription-service' +import { SubscriptionStatus, UserSubscription } from '../../../src/@types/user-subscription' + +const { expect } = chai + +const DAY = 86_400_000 +const NOW = new Date('2026-06-15T00:00:00Z') +const at = (offsetMs: number) => new Date(NOW.getTime() + offsetMs) +const PUBKEY = 'a'.repeat(64) + +const subscription = (overrides: Partial = {}): UserSubscription => ({ + pubkey: PUBKEY, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: at(-28 * DAY), + currentPeriodEnd: at(2 * DAY), + graceEndsAt: at(5 * DAY), + cancelAtPeriodEnd: false, + createdAt: at(-28 * DAY), + updatedAt: at(-28 * DAY), + ...overrides, +}) + +describe('SubscriptionService', () => { + let sandbox: Sinon.SinonSandbox + let repository: Record + let paymentsService: { createInvoice: Sinon.SinonStub; sendRenewalNotification: Sinon.SinonStub } + let currentSettings: Settings + let service: SubscriptionService + + beforeEach(() => { + sandbox = Sinon.createSandbox() + repository = { + findByPubkey: sandbox.stub().resolves(undefined), + upsert: sandbox.stub().resolves(1), + updateIfCurrent: sandbox.stub().resolves(true), + findDueForRenewal: sandbox.stub().resolves([]), + findPeriodEnded: sandbox.stub().resolves([]), + findGraceEnded: sandbox.stub().resolves([]), + } + paymentsService = { + createInvoice: sandbox.stub().resolves({ id: 'invoice-id', bolt11: 'lnbc1...' }), + sendRenewalNotification: sandbox.stub().resolves(), + } + currentSettings = { + info: { name: 'test relay' }, + payments: { + enabled: true, + subscriptionPlans: [ + { id: 'basic', amount: 1_000_000n, intervalSeconds: 30 * 86_400, graceSeconds: 3 * 86_400 }, + ], + }, + } as unknown as Settings + service = new SubscriptionService(repository as any, paymentsService as any, () => currentSettings) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('processLifecycle', () => { + it('does nothing while subscriptions are off', async () => { + currentSettings = { + ...currentSettings, + payments: { enabled: true, subscriptionPlans: [] }, + } as unknown as Settings + + expect(await service.processLifecycle(NOW)).to.equal(0) + expect(repository.findPeriodEnded).not.to.have.been.called + expect(repository.findDueForRenewal).not.to.have.been.called + }) + + it('does nothing while payments are off', async () => { + currentSettings = { + ...currentSettings, + payments: { ...currentSettings.payments, enabled: false }, + } as unknown as Settings + + expect(await service.processLifecycle(NOW)).to.equal(0) + expect(repository.findPeriodEnded).not.to.have.been.called + }) + + describe('renewal invoices', () => { + it('looks for subscriptions ending within the renewal lead time', async () => { + await service.processLifecycle(NOW) + + expect(repository.findDueForRenewal).to.have.been.calledOnce + expect(repository.findDueForRenewal.firstCall.args[0]).to.deep.equal( + new Date(NOW.getTime() + RENEWAL_LEAD_SECONDS * 1000), + ) + }) + + it('issues an invoice for the plan price, tells the subscriber and waits for payment', async () => { + const due = subscription() + repository.findDueForRenewal.onFirstCall().resolves([due]) + + expect(await service.processLifecycle(NOW)).to.equal(1) + + expect(paymentsService.createInvoice).to.have.been.calledOnce + const [pubkey, amount, description, planId] = paymentsService.createInvoice.firstCall.args + expect(pubkey).to.equal(PUBKEY) + expect(amount).to.equal(1_000_000n) + expect(description).to.contain('test relay').and.contain('basic') + expect(planId).to.equal('basic') + expect(paymentsService.sendRenewalNotification).to.have.been.calledOnceWithExactly({ + id: 'invoice-id', + bolt11: 'lnbc1...', + }) + expect(repository.updateIfCurrent).to.have.been.calledOnceWithExactly(due, { + status: SubscriptionStatus.RENEWAL_PENDING, + }) + }) + + it('replaces an expired renewal invoice without touching the status, which is already renewal pending', async () => { + repository.findDueForRenewal + .onFirstCall() + .resolves([subscription({ status: SubscriptionStatus.RENEWAL_PENDING })]) + + expect(await service.processLifecycle(NOW)).to.equal(1) + + expect(paymentsService.createInvoice).to.have.been.calledOnce + expect(paymentsService.sendRenewalNotification).to.have.been.calledOnce + expect(repository.updateIfCurrent).not.to.have.been.called + }) + + it('does not change the status when the invoice cannot be created, so the next pass retries', async () => { + repository.findDueForRenewal.onFirstCall().resolves([subscription()]) + paymentsService.createInvoice.rejects(new Error('processor down')) + + expect(await service.processLifecycle(NOW)).to.equal(0) + + expect(paymentsService.sendRenewalNotification).not.to.have.been.called + expect(repository.updateIfCurrent).not.to.have.been.called + }) + + it('does not count a renewal whose subscription was paid in the meantime', async () => { + repository.findDueForRenewal.onFirstCall().resolves([subscription()]) + repository.updateIfCurrent.resolves(false) + + expect(await service.processLifecycle(NOW)).to.equal(0) + }) + + it('skips a subscription whose plan was removed from the settings', async () => { + repository.findDueForRenewal.onFirstCall().resolves([subscription({ planId: 'retired' })]) + + expect(await service.processLifecycle(NOW)).to.equal(0) + + expect(paymentsService.createInvoice).not.to.have.been.called + expect(repository.updateIfCurrent).not.to.have.been.called + }) + }) + + describe('ended periods', () => { + it('moves a lapsed subscription into grace counted from the period end', async () => { + const periodEnd = at(-DAY) + const lapsed = subscription({ currentPeriodEnd: periodEnd }) + repository.findPeriodEnded.onFirstCall().resolves([lapsed]) + + expect(await service.processLifecycle(NOW)).to.equal(1) + + expect(repository.updateIfCurrent).to.have.been.calledOnce + const [expected, changes] = repository.updateIfCurrent.firstCall.args + expect(expected).to.equal(lapsed) + expect(changes.status).to.equal(SubscriptionStatus.PAST_DUE) + expect(changes.graceEndsAt).to.deep.equal(new Date(periodEnd.getTime() + 3 * DAY)) + }) + + it('also ends a period whose renewal invoice was never paid', async () => { + repository.findPeriodEnded + .onFirstCall() + .resolves([subscription({ status: SubscriptionStatus.RENEWAL_PENDING, currentPeriodEnd: at(-DAY) })]) + + await service.processLifecycle(NOW) + + expect(repository.updateIfCurrent.firstCall.args[1].status).to.equal(SubscriptionStatus.PAST_DUE) + }) + + it('expires a lapsed subscription straight away when the plan has no grace', async () => { + currentSettings.payments.subscriptionPlans[0].graceSeconds = 0 + repository.findPeriodEnded.onFirstCall().resolves([subscription({ currentPeriodEnd: at(-DAY) })]) + + await service.processLifecycle(NOW) + + expect(repository.updateIfCurrent.firstCall.args[1]).to.deep.equal({ + status: SubscriptionStatus.EXPIRED, + graceEndsAt: null, + }) + }) + + it('leaves a subscription whose plan was removed from the settings alone', async () => { + repository.findPeriodEnded.onFirstCall().resolves([subscription({ planId: 'retired' })]) + + expect(await service.processLifecycle(NOW)).to.equal(0) + expect(repository.updateIfCurrent).not.to.have.been.called + }) + + it('does not overwrite a subscription that was paid after it was read', async () => { + repository.findPeriodEnded.onFirstCall().resolves([subscription({ currentPeriodEnd: at(-DAY) })]) + repository.updateIfCurrent.resolves(false) + + expect(await service.processLifecycle(NOW)).to.equal(0) + }) + }) + + describe('grace expiry', () => { + it('expires a subscription whose grace period is over', async () => { + const pastDue = subscription({ + status: SubscriptionStatus.PAST_DUE, + currentPeriodEnd: at(-5 * DAY), + graceEndsAt: at(-DAY), + }) + repository.findGraceEnded.onFirstCall().resolves([pastDue]) + + expect(await service.processLifecycle(NOW)).to.equal(1) + + expect(repository.updateIfCurrent).to.have.been.calledOnceWithExactly(pastDue, { + status: SubscriptionStatus.EXPIRED, + graceEndsAt: null, + }) + }) + + it('expires even when the plan was removed, since the grace end is already recorded', async () => { + repository.findGraceEnded + .onFirstCall() + .resolves([subscription({ planId: 'retired', status: SubscriptionStatus.PAST_DUE, graceEndsAt: at(-DAY) })]) + + expect(await service.processLifecycle(NOW)).to.equal(1) + }) + + it('does not expire a subscription that was paid after it was read', async () => { + repository.findGraceEnded.onFirstCall().resolves([subscription({ status: SubscriptionStatus.PAST_DUE })]) + repository.updateIfCurrent.resolves(false) + + expect(await service.processLifecycle(NOW)).to.equal(0) + }) + }) + + describe('paging', () => { + const page = (count: number, overrides: Partial = {}) => + Array.from({ length: count }, () => subscription({ currentPeriodEnd: at(-DAY), ...overrides })) + + it('asks again while pages come back full, then stops on a short one', async () => { + repository.findPeriodEnded.onCall(0).resolves(page(10)) + repository.findPeriodEnded.onCall(1).resolves(page(3)) + + expect(await service.processLifecycle(NOW)).to.equal(13) + + expect(repository.findPeriodEnded).to.have.been.calledTwice + }) + + it('moves past rows it cannot handle, so they do not block the ones behind them', async () => { + const stuck = page(10, { planId: 'retired' }) + const fine = page(3) + repository.findPeriodEnded.onCall(0).resolves(stuck) + repository.findPeriodEnded.onCall(1).resolves(fine) + + expect(await service.processLifecycle(NOW)).to.equal(3) + + expect(repository.findPeriodEnded.firstCall.args.slice(1)).to.deep.equal([10, 0]) + expect(repository.findPeriodEnded.secondCall.args.slice(1)).to.deep.equal([10, 10]) + }) + + it('carries on behind the rows it skipped in the next pass, so more stuck rows than one pass reads do not hide the rest', async () => { + repository.findPeriodEnded.resolves(page(10, { planId: 'retired' })) + + await service.processLifecycle(NOW) + + // The first pass read ten pages of stuck rows and gave up at the cap; the next starts behind them. + expect(repository.findPeriodEnded.firstCall.args.slice(1)).to.deep.equal([10, 0]) + repository.findPeriodEnded.resetHistory() + repository.findPeriodEnded.resolves([]) + + await service.processLifecycle(NOW) + + expect(repository.findPeriodEnded.firstCall.args.slice(1)).to.deep.equal([10, 100]) + }) + + it('starts over from the front once a pass reaches the end of the rows', async () => { + repository.findPeriodEnded.onCall(0).resolves(page(10, { planId: 'retired' })) + repository.findPeriodEnded.onCall(1).resolves([]) + await service.processLifecycle(NOW) + repository.findPeriodEnded.resetHistory() + repository.findPeriodEnded.resolves([]) + + await service.processLifecycle(NOW) + + expect(repository.findPeriodEnded.firstCall.args.slice(1)).to.deep.equal([10, 0]) + }) + + it('counts only the rows left behind when handled ones drop out of the next query', async () => { + const mixed = [...page(7), ...page(3, { planId: 'retired' })] + repository.findPeriodEnded.onCall(0).resolves(mixed) + repository.findPeriodEnded.onCall(1).resolves([]) + + await service.processLifecycle(NOW) + + expect(repository.findPeriodEnded.secondCall.args.slice(1)).to.deep.equal([10, 3]) + }) + + it('keeps going after one subscription fails', async () => { + repository.findPeriodEnded.onFirstCall().resolves([subscription(), subscription({ pubkey: 'b'.repeat(64) })]) + repository.updateIfCurrent.onFirstCall().rejects(new Error('db error')) + + expect(await service.processLifecycle(NOW)).to.equal(1) + + expect(repository.updateIfCurrent).to.have.been.calledTwice + }) + + it('caps the work of one pass', async () => { + repository.findPeriodEnded.resolves(page(10)) + + expect(await service.processLifecycle(NOW)).to.equal(100) + + expect(repository.findPeriodEnded).to.have.callCount(10) + }) + }) + + it('ends periods before sending renewals, so a lapsed subscription is not also sent an invoice', async () => { + const calls: string[] = [] + repository.findPeriodEnded.callsFake(async () => { + calls.push('ended') + return [] + }) + repository.findGraceEnded.callsFake(async () => { + calls.push('grace') + return [] + }) + repository.findDueForRenewal.callsFake(async () => { + calls.push('renewal') + return [] + }) + + await service.processLifecycle(NOW) + + expect(calls).to.deep.equal(['ended', 'grace', 'renewal']) + }) + }) +}) diff --git a/test/unit/utils/admin-audit-store.spec.ts b/test/unit/utils/admin-audit-store.spec.ts new file mode 100644 index 000000000..45710c194 --- /dev/null +++ b/test/unit/utils/admin-audit-store.spec.ts @@ -0,0 +1,54 @@ +import chai from 'chai' + +import { ICacheAdapter } from '../../../src/@types/adapters' +import { AdminAuditStore } from '../../../src/utils/admin-audit-store' + +const { expect } = chai + +const createCacheStub = (): ICacheAdapter => ({ + getKey: async () => '', + hasKey: async () => false, + setKey: async () => true, + setKeyIfNotExists: async () => true, + addToSortedSet: async () => 1, + removeRangeByScoreFromSortedSet: async () => 0, + removeRangeByRankFromSortedSet: async () => 0, + getRangeFromSortedSet: async () => [], + setKeyExpiry: async () => {}, + deleteKey: async () => 1, + getHKey: async () => '', + setHKey: async () => true, + addToSet: async () => 1, + getSetMembers: async () => [], + eval: async () => '', +}) + +describe('AdminAuditStore', () => { + it('appends entries to the sorted set and trims', async () => { + const cache = createCacheStub() + let addCalls = 0 + cache.addToSortedSet = async () => { + addCalls += 1 + return 1 + } + + const store = new AdminAuditStore(cache) + const entry = await store.append('admin.login', { remoteAddress: '127.0.0.1' }) + + expect(entry.action).to.equal('admin.login') + expect(entry.remoteAddress).to.equal('127.0.0.1') + expect(addCalls).to.equal(1) + }) + + it('lists entries newest-first', async () => { + const older = JSON.stringify({ id: '1', at: '2020-01-01T00:00:00.000Z', action: 'admin.logout' }) + const newer = JSON.stringify({ id: '2', at: '2020-01-02T00:00:00.000Z', action: 'admin.login' }) + const cache = createCacheStub() + cache.getRangeFromSortedSet = async () => [older, newer] + + const store = new AdminAuditStore(cache) + const entries = await store.list(10) + + expect(entries.map((entry) => entry.id)).to.deep.equal(['2', '1']) + }) +}) diff --git a/test/unit/utils/admin-grafana.spec.ts b/test/unit/utils/admin-grafana.spec.ts index c03baa024..a3ebe77df 100644 --- a/test/unit/utils/admin-grafana.spec.ts +++ b/test/unit/utils/admin-grafana.spec.ts @@ -48,13 +48,13 @@ describe('admin-grafana', () => { process.env.GRAFANA_DASHBOARD_UID = 'custom-dashboard' expect(getGrafanaDashboardUrl()).to.equal( - 'http://127.0.0.1:7777/d/custom-dashboard/nostream-overview?orgId=1&refresh=5s&theme=light', + 'http://127.0.0.1:7777/d/custom-dashboard/nostream-overview?orgId=1&refresh=5s&theme=dark', ) expect(getGrafanaEmbedUrl()).to.equal( - 'http://127.0.0.1:7777/d/custom-dashboard/nostream-overview?orgId=1&refresh=5s&theme=light&kiosk', + 'http://127.0.0.1:7777/d/custom-dashboard/nostream-overview?orgId=1&refresh=5s&theme=dark&kiosk', ) expect(getGrafanaSoloPanelUrl(7, 'dark')).to.equal( - 'http://127.0.0.1:7777/d-solo/custom-dashboard?orgId=1&panelId=7&refresh=5s&theme=dark', + 'http://127.0.0.1:7777/d-solo/custom-dashboard/nostream-overview?orgId=1&panelId=7&from=now-15m&to=now&refresh=5s&theme=dark&kiosk=1', ) }) }) diff --git a/test/unit/utils/admin-moderation-persist.spec.ts b/test/unit/utils/admin-moderation-persist.spec.ts new file mode 100644 index 000000000..837aed42d --- /dev/null +++ b/test/unit/utils/admin-moderation-persist.spec.ts @@ -0,0 +1,66 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' + +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +const loadFromRepo = createRequire(join(process.cwd(), 'package.json')) +const adminModeration = loadFromRepo('./src/utils/admin-moderation') as typeof import('../../../src/utils/admin-moderation') +const adminModerationPersist = loadFromRepo('./src/utils/admin-moderation-persist') as typeof import('../../../src/utils/admin-moderation-persist') +const recordAdminAudit = loadFromRepo('./src/utils/record-admin-audit') as typeof import('../../../src/utils/record-admin-audit') +const settingsConfig = loadFromRepo('./src/utils/settings-config') as typeof import('../../../src/utils/settings-config') + +chai.use(sinonChai) + +const { expect } = chai + +const limits = { + pubkey: { blacklist: [], whitelist: [] }, + kind: { blacklist: [], whitelist: [1] }, + rateLimitBypass: { pubkeys: [], ipAddresses: ['127.0.0.1'] }, +} + +describe('saveModerationLimitsFromRequest', () => { + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + sandbox.stub(adminModeration, 'updateAdminModerationLimits').resolves() + sandbox.stub(settingsConfig, 'appendSettingsAuditLog') + sandbox.stub(recordAdminAudit, 'recordAdminAuditFromRequest').resolves() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('persists limits, audits, and enqueues notification', async () => { + const enqueue = sandbox.stub().resolves() + await adminModerationPersist.saveModerationLimitsFromRequest( + () => limits, + { ip: '127.0.0.1' } as any, + { enqueue } as any, + 'moderation.limits.updated', + ) + + expect(adminModeration.updateAdminModerationLimits).to.have.been.calledOnce + expect(adminModeration.updateAdminModerationLimits.firstCall.args[0]).to.be.a('function') + expect(settingsConfig.appendSettingsAuditLog).to.have.been.calledOnce + expect(recordAdminAudit.recordAdminAuditFromRequest).to.have.been.calledOnce + expect(enqueue).to.have.been.calledOnce + }) + + it('still completes when notification enqueue fails', async () => { + const enqueue = sandbox.stub().rejects(new Error('db unavailable')) + await adminModerationPersist.saveModerationLimitsFromRequest( + () => limits, + { ip: '127.0.0.1' } as any, + { enqueue } as any, + 'moderation.action', + { action: 'ban-pubkey' }, + ) + + expect(adminModeration.updateAdminModerationLimits).to.have.been.calledOnce + }) +}) diff --git a/test/unit/utils/admin-moderation-stats.spec.ts b/test/unit/utils/admin-moderation-stats.spec.ts new file mode 100644 index 000000000..e01f6d5d1 --- /dev/null +++ b/test/unit/utils/admin-moderation-stats.spec.ts @@ -0,0 +1,76 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { + parseModerationStatsLimit, + parseModerationStatsWindowHours, + queryTopClientIps, + queryTopPublishingPubkeys, +} from '../../../src/utils/admin-moderation-stats' + +describe('admin-moderation-stats parsing', () => { + it('parses windowHours', () => { + expect(parseModerationStatsWindowHours('24')).to.deep.equal({ hours: 24 }) + expect(parseModerationStatsWindowHours('720').hours).to.equal(720) + }) + + it('parses limit with bounds', () => { + expect(parseModerationStatsLimit('15')).to.equal(15) + expect(parseModerationStatsLimit('200')).to.equal(20) + expect(parseModerationStatsLimit('0')).to.equal(20) + expect(parseModerationStatsLimit(undefined)).to.equal(20) + }) +}) + +describe('admin-moderation-stats queries', () => { + const buildQueryChain = (rows: unknown[]) => { + const chain: Record = {} + chain.select = Sinon.stub().returns(chain) + chain.count = Sinon.stub().returns(chain) + chain.whereNull = Sinon.stub().returns(chain) + chain.where = Sinon.stub().returns(chain) + chain.whereNotNull = Sinon.stub().returns(chain) + chain.groupBy = Sinon.stub().returns(chain) + chain.orderBy = Sinon.stub().returns(chain) + chain.limit = Sinon.stub().returns(chain) + chain.timeout = Sinon.stub().resolves(rows) + return chain + } + + it('maps top publishing pubkeys from the database', async () => { + const chain = buildQueryChain([{ pubkey: 'e'.repeat(64), eventCount: '7' }]) + const db = Sinon.stub().returns(chain) as any + const raw = Sinon.stub().callsFake((sql: string, bindings: unknown[]) => ({ sql, bindings })) + db.raw = raw + + const rows = await queryTopPublishingPubkeys(db, 1_700_000_000, 5) + + expect(rows).to.deep.equal([{ pubkey: 'e'.repeat(64), eventCount: 7 }]) + expect(chain.limit.callCount).to.equal(1) + expect(raw.calledWith(`to_timestamp(?) at time zone 'UTC'`, [1_700_000_000])).to.equal(true) + expect( + chain.where.calledWith('first_seen', '>=', { + sql: `to_timestamp(?) at time zone 'UTC'`, + bindings: [1_700_000_000], + }), + ).to.equal(true) + }) + + it('maps top client IPs from the database', async () => { + const chain = buildQueryChain([{ ip: '203.0.113.4', eventCount: 2 }]) + const db = Sinon.stub().returns(chain) as any + const raw = Sinon.stub().callsFake((sql: string, bindings: unknown[]) => ({ sql, bindings })) + db.raw = raw + + const rows = await queryTopClientIps(db, 1_700_000_000, 10) + + expect(rows).to.deep.equal([{ ip: '203.0.113.4', eventCount: 2 }]) + expect(chain.whereNotNull.callCount).to.equal(1) + expect( + chain.where.calledWith('first_seen', '>=', { + sql: `to_timestamp(?) at time zone 'UTC'`, + bindings: [1_700_000_000], + }), + ).to.equal(true) + }) +}) diff --git a/test/unit/utils/admin-moderation.spec.ts b/test/unit/utils/admin-moderation.spec.ts new file mode 100644 index 000000000..84dde463c --- /dev/null +++ b/test/unit/utils/admin-moderation.spec.ts @@ -0,0 +1,151 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' + +import { + applyAdminModerationAction, + mergeAdminModerationLimitsPatch, + persistAdminModerationLimits, + readAdminModerationLimits, +} from '../../../src/utils/admin-moderation' +import * as settingsConfig from '../../../src/utils/settings-config' + +describe('admin-moderation', () => { + const baseLimits = { + pubkey: { blacklist: [], whitelist: [] }, + kind: { blacklist: [], whitelist: [1] }, + rateLimitBypass: { pubkeys: [], ipAddresses: ['::1'] }, + } + + let previousConfigDir: string | undefined + let tempConfigDir: string + + before(() => { + previousConfigDir = process.env.NOSTR_CONFIG_DIR + tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-admin-moderation-')) + process.env.NOSTR_CONFIG_DIR = tempConfigDir + }) + + after(() => { + if (previousConfigDir === undefined) { + delete process.env.NOSTR_CONFIG_DIR + } else { + process.env.NOSTR_CONFIG_DIR = previousConfigDir + } + fs.rmSync(tempConfigDir, { recursive: true, force: true }) + }) + + beforeEach(() => { + const settings = settingsConfig.loadDefaults() as any + settings.limits.event.pubkey.blacklist = [] + settings.limits.event.pubkey.whitelist = [] + settings.limits.event.kind.blacklist = [] + settings.limits.event.kind.whitelist = [1] + settings.limits.event.whitelists = { pubkeys: [], ipAddresses: ['::1'] } + settings.info = { relay_url: 'wss://relay.example', name: 'test' } + settingsConfig.saveSettings(settings) + }) + + it('reads merged moderation limits', () => { + const limits = readAdminModerationLimits() + expect(limits.kind.whitelist).to.deep.equal([1]) + expect(limits.rateLimitBypass.ipAddresses).to.include('::1') + }) + + it('bans and unbans a pubkey', () => { + const pubkey = 'a'.repeat(64) + const banned = applyAdminModerationAction(baseLimits, 'ban-pubkey', { pubkey }) + expect(banned.pubkey.blacklist).to.include(pubkey) + + const unbanned = applyAdminModerationAction(banned, 'unban-pubkey', { pubkey }) + expect(unbanned.pubkey.blacklist).to.not.include(pubkey) + }) + + it('does not remove a banned pubkey from the event pubkey whitelist', () => { + const pubkey = 'a'.repeat(64) + const withWhitelist = { + ...baseLimits, + pubkey: { blacklist: [], whitelist: [pubkey] }, + } + const banned = applyAdminModerationAction(withWhitelist, 'ban-pubkey', { pubkey }) + expect(banned.pubkey.whitelist).to.deep.equal([pubkey]) + expect(banned.pubkey.blacklist).to.include(pubkey) + }) + + it('merges partial limit patches', () => { + const merged = mergeAdminModerationLimitsPatch(baseLimits, { + pubkey: { blacklist: ['b'.repeat(64)] }, + }) + expect(merged.pubkey.blacklist).to.have.length(1) + expect(merged.kind.whitelist).to.deep.equal([1]) + }) + + it('whitelists and removes rate-limit bypass pubkeys', () => { + const pubkey = 'd'.repeat(64) + const withPubkey = applyAdminModerationAction(baseLimits, 'whitelist-pubkey', { pubkey }) + expect(withPubkey.rateLimitBypass.pubkeys).to.include(pubkey) + + const withoutPubkey = applyAdminModerationAction(withPubkey, 'unwhitelist-pubkey', { pubkey }) + expect(withoutPubkey.rateLimitBypass.pubkeys).to.not.include(pubkey) + }) + + it('rejects invalid pubkeys and IPs for moderation actions', () => { + expect(() => applyAdminModerationAction(baseLimits, 'ban-pubkey', { pubkey: 'short' })).to.throw( + /64-character hex pubkey/, + ) + expect(() => applyAdminModerationAction(baseLimits, 'whitelist-ip', { ip: 'not-an-ip' })).to.throw(/valid IPv4/) + }) + + it('whitelists and removes rate-limit bypass for IPs', () => { + const withIp = applyAdminModerationAction(baseLimits, 'whitelist-ip', { ip: '203.0.113.10' }) + expect(withIp.rateLimitBypass.ipAddresses).to.include('203.0.113.10') + + const withoutIp = applyAdminModerationAction(withIp, 'unwhitelist-ip', { ip: '203.0.113.10' }) + expect(withoutIp.rateLimitBypass.ipAddresses).to.not.include('203.0.113.10') + }) + + it('normalizes IPv6 bypass addresses to match client socket spelling', () => { + const expanded = '2001:0db8:0:0:0:0:0:1' + const withIp = applyAdminModerationAction(baseLimits, 'whitelist-ip', { ip: expanded }) + expect(withIp.rateLimitBypass.ipAddresses).to.include('2001:db8::1') + + const withoutIp = applyAdminModerationAction(withIp, 'unwhitelist-ip', { ip: '2001:db8::1' }) + expect(withoutIp.rateLimitBypass.ipAddresses).to.not.include('2001:db8::1') + }) + + it('removes IPv6 bypass entries stored in alternate spelling', () => { + const limits = { + ...baseLimits, + rateLimitBypass: { + ...baseLimits.rateLimitBypass, + ipAddresses: ['2001:0db8:0:0:0:0:0:1'], + }, + } + const withoutIp = applyAdminModerationAction(limits, 'unwhitelist-ip', { ip: '2001:db8::1' }) + expect(withoutIp.rateLimitBypass.ipAddresses).to.be.empty + }) + + it('persists normalized moderation limits to settings', async () => { + const pubkey = 'c'.repeat(64) + await persistAdminModerationLimits({ + ...baseLimits, + pubkey: { blacklist: [pubkey], whitelist: [] }, + }) + + const limits = readAdminModerationLimits() + expect(limits.pubkey.blacklist).to.include(pubkey) + }) + + it('persists when user settings only override part of info', async () => { + settingsConfig.saveSettings({ info: { relay_url: 'wss://relay.example' } } as any) + const pubkey = 'f'.repeat(64) + + await persistAdminModerationLimits({ + ...baseLimits, + pubkey: { blacklist: [pubkey], whitelist: [] }, + }) + + expect(readAdminModerationLimits().pubkey.blacklist).to.include(pubkey) + }) +}) diff --git a/test/unit/utils/admin-notifications-settings.spec.ts b/test/unit/utils/admin-notifications-settings.spec.ts index 27997a927..df7ec084d 100644 --- a/test/unit/utils/admin-notifications-settings.spec.ts +++ b/test/unit/utils/admin-notifications-settings.spec.ts @@ -69,6 +69,41 @@ describe('admin-notifications-settings', () => { expect(next.targets[0].botToken).to.equal(undefined) }) + it('clears signing secret when patch sends an empty string', () => { + const current = getMergedAdminNotifications({ + admin: { + notifications: { + enabled: true, + targets: [ + { + id: 'http-1', + type: 'http', + enabled: true, + url: 'https://example.com/hook', + secret: 'old-secret', + }, + ], + events: {}, + retry: { maxAttempts: 5, baseDelayMs: 1000 }, + }, + }, + } as any) + + const next = mergeAdminNotificationsPatch(current, { + targets: [ + { + id: 'http-1', + type: 'http', + enabled: true, + url: 'https://example.com/hook', + secret: '', + }, + ], + }) + + expect(next.targets[0].secret).to.equal(undefined) + }) + it('does not persist redacted webhook url for new http targets', () => { const current = getMergedAdminNotifications({ admin: { diff --git a/test/unit/utils/admin-ops-config.spec.ts b/test/unit/utils/admin-ops-config.spec.ts new file mode 100644 index 000000000..7694db13f --- /dev/null +++ b/test/unit/utils/admin-ops-config.spec.ts @@ -0,0 +1,43 @@ +import { expect } from 'chai' + +import { listConfiguredWorkers, mergeWorkerRows } from '../../../src/utils/admin-ops-config' +import { Settings } from '../../../src/@types/settings' +import { ADMIN_OPS_HEARTBEAT_TTL_SECONDS, isAdminOpsHeartbeatStale } from '../../../src/utils/admin-ops-store' + +describe('admin-ops-config', () => { + it('marks unmonitored workers as not stale without a heartbeat', () => { + const rows = mergeWorkerRows( + [{ type: 'worker', index: '0', enabled: true, label: 'Client worker 0' }], + [], + ) + + expect(rows[0].monitored).to.equal(false) + expect(rows[0].stale).to.equal(false) + }) + + it('marks monitored workers stale when heartbeat is expired', () => { + const staleUpdatedAt = new Date(Date.now() - (ADMIN_OPS_HEARTBEAT_TTL_SECONDS + 5) * 1000).toISOString() + const rows = mergeWorkerRows( + [{ type: 'maintenance', enabled: true, label: 'Maintenance' }], + [{ type: 'maintenance', updatedAt: staleUpdatedAt }], + ) + + expect(rows[0].monitored).to.equal(true) + expect(rows[0].stale).to.equal(true) + }) + + it('decides when a heartbeat is stale from the time it was last updated', () => { + const freshUpdatedAt = new Date().toISOString() + + expect(isAdminOpsHeartbeatStale(freshUpdatedAt, ADMIN_OPS_HEARTBEAT_TTL_SECONDS)).to.equal(false) + }) + + it('no longer lists mirroring workers, even for a mirroring.static that is still configured', () => { + const workers = listConfiguredWorkers({ + workers: { count: 1 }, + mirroring: { static: [{ address: 'wss://mirror.example' }] }, + } as unknown as Settings) + + expect(workers.map((worker) => worker.type)).to.deep.equal(['worker', 'maintenance']) + }) +}) diff --git a/test/unit/utils/admin-ops-nip05.spec.ts b/test/unit/utils/admin-ops-nip05.spec.ts new file mode 100644 index 000000000..be0ad20bc --- /dev/null +++ b/test/unit/utils/admin-ops-nip05.spec.ts @@ -0,0 +1,35 @@ +import { expect } from 'chai' + +import { isNip05VerificationStale, toAdminNip05Row } from '../../../src/utils/admin-ops-nip05' + +describe('admin-ops-nip05', () => { + it('marks unverified rows as stale', () => { + const row = toAdminNip05Row( + { + pubkey: 'aa'.repeat(32), + nip05: 'user@example.com', + domain: 'example.com', + isVerified: false, + failureCount: 2, + lastVerifiedAt: null, + lastCheckedAt: new Date('2020-01-01T00:00:00.000Z'), + createdAt: new Date('2020-01-01T00:00:00.000Z'), + updatedAt: new Date('2020-01-01T00:00:00.000Z'), + }, + 86_400_000, + ) + + expect(row.stale).to.equal(true) + }) + + it('treats recently verified rows as fresh', () => { + const now = Date.now() + expect( + isNip05VerificationStale( + { isVerified: true, lastVerifiedAt: new Date(now - 1000) }, + 86_400_000, + now, + ), + ).to.equal(false) + }) +}) diff --git a/test/unit/utils/admin-ops-sanitize.spec.ts b/test/unit/utils/admin-ops-sanitize.spec.ts new file mode 100644 index 000000000..a4778e07c --- /dev/null +++ b/test/unit/utils/admin-ops-sanitize.spec.ts @@ -0,0 +1,19 @@ +import { expect } from 'chai' + +import { sanitizeAdminOpsError } from '../../../src/utils/admin-ops-sanitize' + +describe('sanitizeAdminOpsError', () => { + it('redacts urls and truncates long messages', () => { + const message = `failed wss://secret.relay.example/nostr Bearer token123 ${'x'.repeat(300)}` + const sanitized = sanitizeAdminOpsError(message) + + expect(sanitized).to.include('[url]') + expect(sanitized).to.include('Bearer [redacted]') + expect(sanitized!.length).to.be.at.most(241) + }) + + it('returns undefined for empty input', () => { + expect(sanitizeAdminOpsError(undefined)).to.equal(undefined) + expect(sanitizeAdminOpsError(' ')).to.equal(undefined) + }) +}) diff --git a/test/unit/utils/admin-ops-storage.spec.ts b/test/unit/utils/admin-ops-storage.spec.ts new file mode 100644 index 000000000..07197742e --- /dev/null +++ b/test/unit/utils/admin-ops-storage.spec.ts @@ -0,0 +1,26 @@ +import { expect } from 'chai' + +import { buildStorageCountFilters, parseAdminOpsStorageQuery } from '../../../src/utils/admin-ops-storage' + +describe('admin-ops-storage', () => { + it('requires kinds parameter', () => { + const result = parseAdminOpsStorageQuery(undefined, undefined, undefined) + expect(result.error).to.match(/kinds/) + }) + + it('parses kinds and builds per-kind filters', () => { + const parsed = parseAdminOpsStorageQuery('1,7,7', '100', '200') + expect(parsed.error).to.be.undefined + expect(parsed.query?.kinds).to.deep.equal([1, 7]) + + const filters = buildStorageCountFilters(parsed.query!) + expect(filters).to.have.length(2) + expect(filters[0]).to.deep.equal({ since: 100, until: 200, kinds: [1] }) + expect(filters[1]).to.deep.equal({ since: 100, until: 200, kinds: [7] }) + }) + + it('rejects invalid kind values', () => { + const result = parseAdminOpsStorageQuery('1,abc', undefined, undefined) + expect(result.error).to.match(/kinds/) + }) +}) diff --git a/test/unit/utils/admin-ops-store.spec.ts b/test/unit/utils/admin-ops-store.spec.ts new file mode 100644 index 000000000..9451677a5 --- /dev/null +++ b/test/unit/utils/admin-ops-store.spec.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai' + +import { ICacheAdapter } from '../../../src/@types/adapters' +import { AdminOpsStore } from '../../../src/utils/admin-ops-store' + +const createRecordingCache = (): { cache: ICacheAdapter; workerPayloads: string[] } => { + const workerPayloads: string[] = [] + const cache: ICacheAdapter = { + getKey: async () => '', + hasKey: async () => false, + setKey: async (key, value) => { + if (key.startsWith('admin:ops:worker:')) { + workerPayloads.push(value) + } + return true + }, + setKeyIfNotExists: async () => true, + addToSortedSet: async () => 0, + removeRangeByScoreFromSortedSet: async () => 0, + removeRangeByRankFromSortedSet: async () => 0, + getRangeFromSortedSet: async () => [], + setKeyExpiry: async () => {}, + deleteKey: async () => 0, + getHKey: async () => '', + setHKey: async () => true, + addToSet: async () => 1, + getSetMembers: async () => [], + eval: async () => null, + } + + return { cache, workerPayloads } +} + +describe('AdminOpsStore', () => { + it('overwrites worker heartbeats with the latest publish payload', async () => { + const { cache, workerPayloads } = createRecordingCache() + const store = new AdminOpsStore(cache) + + await store.publishWorker({ + type: 'relay-monitor', + updatedAt: '2024-01-01T00:00:00.000Z', + lastRunFinishedAt: '2024-01-01T00:00:00.000Z', + lastError: 'probe failed', + }) + + await store.publishWorker({ + type: 'relay-monitor', + updatedAt: '2024-01-01T00:00:45.000Z', + lastRunFinishedAt: '2024-01-01T00:00:00.000Z', + lastError: 'probe failed', + }) + + expect(workerPayloads).to.have.length(2) + const latest = JSON.parse(workerPayloads[1]!) + expect(latest.lastError).to.equal('probe failed') + expect(latest.updatedAt).to.equal('2024-01-01T00:00:45.000Z') + }) +}) diff --git a/test/unit/utils/admin-rate-limit.spec.ts b/test/unit/utils/admin-rate-limit.spec.ts index 8ff26fa04..e9120a2a3 100644 --- a/test/unit/utils/admin-rate-limit.spec.ts +++ b/test/unit/utils/admin-rate-limit.spec.ts @@ -29,7 +29,7 @@ describe('isAdminRateLimited', () => { }) as any it('returns false when no rate limits are configured', async () => { - const rateLimiter = { hit: sinon.stub().resolves(false) } + const rateLimiter = { hit: sinon.stub().resolves({ limited: false }) } const settings = { ...baseSettings, limits: { admin: { ipWhitelist: [] } } } const limited = await isAdminRateLimited(makeRequest(), settings as any, () => rateLimiter, 'admin') @@ -39,7 +39,7 @@ describe('isAdminRateLimited', () => { }) it('returns true when login rate limit is exceeded', async () => { - const rateLimiter = { hit: sinon.stub().resolves(true) } + const rateLimiter = { hit: sinon.stub().resolves({ limited: true }) } const limited = await isAdminRateLimited(makeRequest(), baseSettings as any, () => rateLimiter, 'login') @@ -51,7 +51,7 @@ describe('isAdminRateLimited', () => { }) it('returns true when admin route rate limit is exceeded', async () => { - const rateLimiter = { hit: sinon.stub().resolves(true) } + const rateLimiter = { hit: sinon.stub().resolves({ limited: true }) } const limited = await isAdminRateLimited(makeRequest(), baseSettings as any, () => rateLimiter, 'admin') diff --git a/test/unit/utils/banner.spec.ts b/test/unit/utils/banner.spec.ts new file mode 100644 index 000000000..ede195cb5 --- /dev/null +++ b/test/unit/utils/banner.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai' + +import { BANNER_WIDTH, centre, isStructuredLogFormat, RELAY_BANNER_LINES } from '../../../src/utils/banner' + +describe('startup banner', () => { + // The banner is printed to stdout as text. A line wider than the block would + // wrap in a terminal and turn the logo into noise. + it('fits every line inside the banner width', () => { + for (const line of RELAY_BANNER_LINES) { + expect(line.length, line).to.be.at.most(BANNER_WIDTH) + } + }) + + it('centres a short line', () => { + const padded = centre('v1.2.3') + + expect(padded).to.equal(' '.repeat((BANNER_WIDTH - 6) >> 1) + 'v1.2.3') + expect(padded.trimEnd()).to.equal(padded) + }) + + // Half a logo is worse than a wide one, so an overlong line is left alone + // rather than cut to fit. + it('never truncates a line longer than the width', () => { + const long = 'x'.repeat(BANNER_WIDTH + 10) + + expect(centre(long)).to.equal(long) + }) + + it('pads nothing when the line is exactly the width', () => { + const exact = 'x'.repeat(BANNER_WIDTH) + + expect(centre(exact)).to.equal(exact) + }) + + // LOG_FORMAT=json is a promise made to a log pipeline, so it is read strictly: + // an unrecognised value falls back to the banner a person can read. + it('reads LOG_FORMAT=json, trimmed and case-insensitively', () => { + expect(isStructuredLogFormat('json')).to.equal(true) + expect(isStructuredLogFormat(' JSON ')).to.equal(true) + }) + + it('keeps the banner for anything else', () => { + expect(isStructuredLogFormat(undefined)).to.equal(false) + expect(isStructuredLogFormat('')).to.equal(false) + expect(isStructuredLogFormat('pretty')).to.equal(false) + expect(isStructuredLogFormat('jsonlines')).to.equal(false) + }) +}) diff --git a/test/unit/utils/ewma-rate-limiter.spec.ts b/test/unit/utils/ewma-rate-limiter.spec.ts index 53ec8f62e..40f32f2cb 100644 --- a/test/unit/utils/ewma-rate-limiter.spec.ts +++ b/test/unit/utils/ewma-rate-limiter.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai' import Sinon from 'sinon' -import { calculateEWMA, EWMARateLimiter } from '../../../src/utils/ewma-rate-limiter' +import { calculateEWMA, EWMA_KEY_PREFIX, EWMARateLimiter, ewmaKey } from '../../../src/utils/ewma-rate-limiter' import { ICacheAdapter } from '../../../src/@types/adapters' import { IRateLimiter } from '../../../src/@types/utils' @@ -57,16 +57,81 @@ describe('EWMARateLimiter', () => { }) describe('hit', () => { - it('returns false on first request', async () => { - evalStub.resolves(0) + it('reports no limit below the rate', async () => { + evalStub.resolves('1') const result = await rateLimiter.hit('key', 1, { period: 120000, rate: 10 }) - expect(result).to.be.false + expect(result.limited).to.be.false + expect(result.retryAfterMs).to.be.undefined }) - it('returns true when rate limit exceeded', async () => { - evalStub.resolves(1) - const result = await rateLimiter.hit('key', 1, { period: 120000, rate: 10 }) - expect(result).to.be.true + it('reports a limit above the rate', async () => { + evalStub.resolves('24') + const result = await rateLimiter.hit('key', 1, { period: 60000, rate: 6 }) + expect(result.limited).to.be.true + }) + + // The script returns the score, not a verdict, so a truncated integer + // would make the backoff wrong: 6.4 must not arrive as 6. + it('reads a fractional score', async () => { + evalStub.resolves('6.4') + const result = await rateLimiter.hit('key', 1, { period: 60000, rate: 6 }) + expect(result.limited).to.be.true + expect(result.retryAfterMs).to.equal(Math.ceil(60000 * Math.log2(6.4 / 5))) + }) + + // `period` is the half-life, and the next hit adds its step before the + // comparison, so the score has to fall to `rate - step`: 24 down to 5 at + // half-life 60 s is 2.26 periods, ~136 s, not the 60 s we used to send. + it('sends the half-life backoff the next hit actually needs', async () => { + evalStub.resolves('24') + const result = await rateLimiter.hit('key', 1, { period: 60000, rate: 6 }) + expect(result.retryAfterMs).to.equal(135783) + }) + + // rate 1 with step 1 leaves no headroom: no decay admits another hit, so + // the honest answer is one period, which is also the key's TTL. + it('falls back to the period when no decay can admit a hit', async () => { + evalStub.resolves('3') + const result = await rateLimiter.hit('key', 1, { period: 60000, rate: 1 }) + expect(result.retryAfterMs).to.equal(60000) + }) + + it('never sends a zero or sub-millisecond backoff', async () => { + evalStub.resolves('6.0000001') + const result = await rateLimiter.hit('key', 1, { period: 60000, rate: 6 }) + expect(result.limited).to.be.true + expect(result.retryAfterMs).to.be.at.least(1) + }) + + // A non-numeric answer means the script and the client disagree about + // the protocol. Reading it as "allowed" would silently disable the + // limiter; the caller fails closed on a throw. + it('throws when the script returns something that is not a score', async () => { + evalStub.resolves(null) + let error: unknown + try { + await rateLimiter.hit('key', 1, { period: 120000, rate: 10 }) + } catch (caught) { + error = caught + } + expect(error).to.be.instanceOf(Error) + }) + + // EWMA and sliding_window store different Redis types under the same + // logical key name, so they must not share the physical key: a 2.x + // volume, or an operator switching strategy, leaves a sorted set where + // EWMA would then HSET and fail with WRONGTYPE on every request. + it('namespaces the key it writes', async () => { + evalStub.resolves('0') + await rateLimiter.hit('1.2.3.4:connection:120000', 1, { period: 120000, rate: 10 }) + + expect(evalStub.calledOnce).to.be.true + expect(evalStub.firstCall.args[1]).to.deep.equal(['ewma:1.2.3.4:connection:120000']) + }) + + it('builds the namespaced key from any caller key', () => { + expect(ewmaKey('abc:message:60000')).to.equal('ewma:abc:message:60000') + expect(EWMA_KEY_PREFIX).to.equal('ewma:') }) }) }) diff --git a/test/unit/utils/messages.spec.ts b/test/unit/utils/messages.spec.ts index cff5ad242..0252166ef 100644 --- a/test/unit/utils/messages.spec.ts +++ b/test/unit/utils/messages.spec.ts @@ -4,6 +4,8 @@ import { createClosedMessage, createCommandResult, createEndOfStoredEventsNoticeMessage, + createNegErrMessage, + createNegMsgMessage, createNoticeMessage, createOutgoingEventMessage, createRelayedEventMessage, @@ -147,3 +149,32 @@ describe('createRelayedEventMessage', () => { ]) }) }) + +describe('NIP-77 messages', () => { + it('createNegMsgMessage returns a NEG-MSG with the hex payload', () => { + expect(createNegMsgMessage('sync1', '6100')).to.deep.equal([MessageType.NEG_MSG, 'sync1', '6100']) + }) + + it('createNegErrMessage returns a NEG-ERR with the reason', () => { + expect(createNegErrMessage('sync1', 'closed: bye')).to.deep.equal([MessageType.NEG_ERR, 'sync1', 'closed: bye']) + }) + + it('createNegErrMessage appends the record limit when given', () => { + expect(createNegErrMessage('sync1', 'blocked: this query is too big', 5000)).to.deep.equal([ + MessageType.NEG_ERR, + 'sync1', + 'blocked: this query is too big', + 5000, + ]) + }) + + it('createNegErrMessage keeps a record limit of 0', () => { + expect(createNegErrMessage('sync1', 'blocked: no', 0)).to.have.lengthOf(4) + }) + + it('serialises to the JSON array NIP-77 describes', () => { + expect(JSON.stringify(createNegErrMessage('sync1', 'blocked: no', 3))).to.equal( + '["NEG-ERR","sync1","blocked: no",3]', + ) + }) +}) diff --git a/test/unit/utils/moderation-rate-limit-stats.spec.ts b/test/unit/utils/moderation-rate-limit-stats.spec.ts new file mode 100644 index 000000000..a3c75a448 --- /dev/null +++ b/test/unit/utils/moderation-rate-limit-stats.spec.ts @@ -0,0 +1,67 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { ICacheAdapter } from '../../../src/@types/adapters' +import { + listModerationRateLimitHits, + recordModerationRateLimitHitSafely, + utcDayBucketSuffix, +} from '../../../src/utils/moderation-rate-limit-stats' + +describe('moderation-rate-limit-stats', () => { + it('formats UTC day bucket suffixes', () => { + const suffix = utcDayBucketSuffix(Date.UTC(2026, 9, 23, 15, 30, 0)) + expect(suffix).to.equal('20261023') + }) + + it('skips recording when cache has no eval', async () => { + const cache = {} as ICacheAdapter + await recordModerationRateLimitHitSafely(cache, { pubkey: 'a'.repeat(64), ip: '127.0.0.1' }) + }) + + it('swallows redis eval failures when recording hits', async () => { + const evalStub = Sinon.stub().rejects(new Error('redis unavailable')) + const cache = { eval: evalStub } as unknown as ICacheAdapter + + await recordModerationRateLimitHitSafely(cache, { pubkey: 'a'.repeat(64), ip: '127.0.0.1' }) + + expect(evalStub.callCount).to.be.greaterThan(0) + }) + + it('records pubkey and ip hits via eval', async () => { + const evalStub = Sinon.stub().resolves(1) + const cache = { eval: evalStub } as unknown as ICacheAdapter + + await recordModerationRateLimitHitSafely(cache, { pubkey: 'A'.repeat(64), ip: '127.0.0.1' }) + + expect(evalStub.callCount).to.equal(2) + expect(evalStub.firstCall.args[1][0]).to.match(/^admin:moderation:rate-limit:pubkeys:\d{8}$/) + expect(evalStub.firstCall.args[2][0]).to.equal('a'.repeat(64)) + }) + + it('aggregates every UTC day bucket touched by a 24h rolling window', async () => { + const evalStub = Sinon.stub().resolves([]) + const cache = { eval: evalStub } as unknown as ICacheAdapter + const nowMs = Date.UTC(2026, 9, 24, 15, 0, 0) + + await listModerationRateLimitHits(cache, 5, 24, nowMs) + + const dayBuckets = (evalStub.firstCall.args[1] as string[]).slice(1) + expect(dayBuckets).to.have.length(2) + expect(dayBuckets[0]).to.match(/:20261023$/) + expect(dayBuckets[1]).to.match(/:20261024$/) + }) + + it('lists aggregated top hits from redis eval', async () => { + const evalStub = Sinon.stub() + evalStub.onFirstCall().resolves(['pubkey-a', '3']) + evalStub.onSecondCall().resolves(['127.0.0.1', '2']) + const cache = { eval: evalStub } as unknown as ICacheAdapter + + const result = await listModerationRateLimitHits(cache, 5, 24) + + expect(result.pubkeys).to.deep.equal([{ key: 'pubkey-a', hits: 3 }]) + expect(result.ips).to.deep.equal([{ key: '127.0.0.1', hits: 2 }]) + expect(evalStub.callCount).to.equal(2) + }) +}) diff --git a/test/unit/utils/negentropy-sessions.spec.ts b/test/unit/utils/negentropy-sessions.spec.ts new file mode 100644 index 000000000..ba1ce6183 --- /dev/null +++ b/test/unit/utils/negentropy-sessions.spec.ts @@ -0,0 +1,232 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { NegentropySession } from '../../../src/@types/negentropy' +import { Negentropy, NegentropyStorageVector } from '../../../src/utils/negentropy' +import { NegentropySessions } from '../../../src/utils/negentropy-sessions' + +chai.use(sinonChai) +const { expect } = chai + +const newSession = (filter: NegentropySession['filter'] = {}): NegentropySession => { + const storage = new NegentropyStorageVector() + storage.seal() + + return { negentropy: new Negentropy(storage), filter } +} + +describe('NegentropySessions', () => { + let clock: Sinon.SinonFakeTimers + let onIdleTimeout: Sinon.SinonStub + let sessions: NegentropySessions + + beforeEach(() => { + clock = Sinon.useFakeTimers() + onIdleTimeout = Sinon.stub() + sessions = new NegentropySessions(onIdleTimeout) + }) + + afterEach(() => { + sessions.closeAll() + clock.restore() + }) + + describe('reserve and activate', () => { + it('stores a session once it is activated', () => { + const session = newSession() + + const token = sessions.reserve('a', 3) + expect(token).to.not.be.undefined + expect(sessions.activate('a', token as symbol, session, 1000)).to.equal(true) + + expect(sessions.touch('a')).to.equal(session) + expect(sessions.getSessionIds()).to.deep.equal(['a']) + }) + + it('does not list a reservation that is not active yet', () => { + sessions.reserve('a', 3) + + expect(sessions.getSessionIds()).to.deep.equal([]) + expect(sessions.touch('a')).to.be.undefined + }) + + it('refuses a new id once the limit is reached, counting reservations', () => { + sessions.reserve('a', 2) + sessions.reserve('b', 2) + + expect(sessions.reserve('c', 2)).to.be.undefined + }) + + it('lets an id that is already open be reopened at the limit, replacing the old session', () => { + const first = newSession() + const second = newSession() + const token1 = sessions.reserve('a', 1) as symbol + sessions.activate('a', token1, first, 1000) + + const token2 = sessions.reserve('a', 1) + expect(token2).to.not.be.undefined + sessions.activate('a', token2 as symbol, second, 1000) + + expect(sessions.touch('a')).to.equal(second) + }) + + it('refuses to activate a reservation that was replaced meanwhile', () => { + const stale = sessions.reserve('a', 3) as symbol + sessions.reserve('a', 3) + + expect(sessions.activate('a', stale, newSession(), 1000)).to.equal(false) + expect(sessions.touch('a')).to.be.undefined + }) + + it('refuses to activate a reservation that was closed meanwhile', () => { + const token = sessions.reserve('a', 3) as symbol + sessions.close('a') + + expect(sessions.activate('a', token, newSession(), 1000)).to.equal(false) + }) + + it('frees the slot when a reservation is released and its read has ended', () => { + const token = sessions.reserve('a', 1) as symbol + + sessions.release('a', token) + sessions.endRead(token) + + expect(sessions.reserve('b', 1)).to.not.be.undefined + }) + + it('does not release a newer reservation with an old token', () => { + const stale = sessions.reserve('a', 3) as symbol + const current = sessions.reserve('a', 3) as symbol + + sessions.release('a', stale) + + expect(sessions.activate('a', current, newSession(), 1000)).to.equal(true) + }) + }) + + describe('reads in flight', () => { + it('keeps counting the read of a replaced request until it ends', () => { + sessions.reserve('a', 2) + sessions.reserve('a', 2) // replaces the first, whose read is still running + + expect(sessions.reserve('a', 2)).to.be.undefined + }) + + it('stops counting a read once it ends', () => { + const first = sessions.reserve('a', 2) as symbol + sessions.reserve('a', 2) + + sessions.endRead(first) + + expect(sessions.reserve('a', 2)).to.not.be.undefined + }) + + it('does not count a read twice or after it was never started', () => { + const token = sessions.reserve('a', 2) as symbol + + sessions.endRead(token) + sessions.endRead(token) + + expect(sessions.reserve('b', 2)).to.not.be.undefined + expect(sessions.reserve('c', 2)).to.be.undefined // the two sessions a and b fill the limit + }) + + it('counts a read as over once its session is activated', () => { + const token = sessions.reserve('a', 1) as symbol + sessions.activate('a', token, newSession(), 1000) + + expect(sessions.reserve('a', 1)).to.not.be.undefined + }) + + it('reports whether a token still owns its id', () => { + const first = sessions.reserve('a', 3) as symbol + expect(sessions.isCurrent('a', first)).to.equal(true) + + const second = sessions.reserve('a', 3) as symbol + + expect(sessions.isCurrent('a', first)).to.equal(false) + expect(sessions.isCurrent('a', second)).to.equal(true) + sessions.close('a') + expect(sessions.isCurrent('a', second)).to.equal(false) + }) + + it('keeps the filter a session was opened with', () => { + const filter = { kinds: [1], authors: ['a'.repeat(64)] } + sessions.activate('a', sessions.reserve('a', 3) as symbol, newSession(filter), 1000) + + expect(sessions.touch('a')?.filter).to.deep.equal(filter) + }) + }) + + describe('close', () => { + it('removes a session and reports whether there was one', () => { + sessions.activate('a', sessions.reserve('a', 3) as symbol, newSession(), 1000) + + expect(sessions.close('a')).to.equal(true) + expect(sessions.close('a')).to.equal(false) + expect(sessions.touch('a')).to.be.undefined + }) + + it('closeAll removes every session and reservation', () => { + sessions.activate('a', sessions.reserve('a', 3) as symbol, newSession(), 1000) + const pending = sessions.reserve('b', 3) as symbol + + sessions.closeAll() + sessions.endRead(pending) + + expect(sessions.getSessionIds()).to.deep.equal([]) + expect(sessions.reserve('c', 1)).to.not.be.undefined + }) + }) + + describe('idle timeout', () => { + it('closes an idle session and tells the caller', () => { + sessions.activate('a', sessions.reserve('a', 3) as symbol, newSession(), 1000) + + clock.tick(1000) + + expect(onIdleTimeout).to.have.been.calledOnceWithExactly('a') + expect(sessions.touch('a')).to.be.undefined + }) + + it('restarts the timer whenever the session is touched', () => { + sessions.activate('a', sessions.reserve('a', 3) as symbol, newSession(), 1000) + + clock.tick(900) + sessions.touch('a') + clock.tick(900) + + expect(onIdleTimeout).to.not.have.been.called + clock.tick(100) + expect(onIdleTimeout).to.have.been.calledOnceWithExactly('a') + }) + + it('does not fire for a session closed before the timeout', () => { + sessions.activate('a', sessions.reserve('a', 3) as symbol, newSession(), 1000) + + sessions.close('a') + clock.tick(5000) + + expect(onIdleTimeout).to.not.have.been.called + }) + + it('does not fire for a replaced session', () => { + sessions.activate('a', sessions.reserve('a', 3) as symbol, newSession(), 1000) + sessions.activate('a', sessions.reserve('a', 3) as symbol, newSession(), 10000) + + clock.tick(1000) + + expect(onIdleTimeout).to.not.have.been.called + }) + + it('never times out when the timeout is not positive', () => { + sessions.activate('a', sessions.reserve('a', 3) as symbol, newSession(), 0) + + clock.tick(10 * 60 * 1000) + + expect(onIdleTimeout).to.not.have.been.called + expect(sessions.getSessionIds()).to.deep.equal(['a']) + }) + }) +}) diff --git a/test/unit/utils/nip05-reverification.spec.ts b/test/unit/utils/nip05-reverification.spec.ts new file mode 100644 index 000000000..a38efdbc0 --- /dev/null +++ b/test/unit/utils/nip05-reverification.spec.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { Nip05Verification } from '../../../src/@types/nip05' +import * as nip05 from '../../../src/utils/nip05' +import { reverifyNip05Verification } from '../../../src/utils/nip05-reverification' + +const baseVerification = (): Nip05Verification => ({ + pubkey: 'aa'.repeat(32), + nip05: 'user@example.com', + domain: 'example.com', + isVerified: false, + lastVerifiedAt: null, + lastCheckedAt: new Date('2024-01-01T00:00:00.000Z'), + failureCount: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), +}) + +describe('reverifyNip05Verification', () => { + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('does not upsert when the nip05 identity changed during verification', async () => { + const verification = baseVerification() + const repository = { + findByPubkey: sandbox.stub().resolves({ + ...verification, + nip05: 'other@example.com', + }), + upsert: sandbox.stub().resolves(1), + } + + sandbox.stub(nip05, 'verifyNip05Identifier').resolves({ status: 'verified' }) + + const result = await reverifyNip05Verification(verification, repository as any) + + expect(result.nip05).to.equal('other@example.com') + expect(repository.upsert).to.not.have.been.called + }) + + it('upserts when the stored row still matches the requested nip05', async () => { + const verification = baseVerification() + const repository = { + findByPubkey: sandbox.stub().resolves({ ...verification }), + upsert: sandbox.stub().resolves(1), + } + + sandbox.stub(nip05, 'verifyNip05Identifier').resolves({ status: 'verified' }) + + await reverifyNip05Verification(verification, repository as any) + + expect(repository.upsert).to.have.been.calledOnce + }) +}) diff --git a/test/unit/utils/nip42.spec.ts b/test/unit/utils/nip42.spec.ts index efba87f8e..b2310b995 100644 --- a/test/unit/utils/nip42.spec.ts +++ b/test/unit/utils/nip42.spec.ts @@ -7,6 +7,7 @@ import { isAuthRequired, isClientAuthorizedToReadMention, isCountAuthorized, + isReconciliationAuthorized, isSubscriptionAuthRequired, } from '../../../src/utils/nip42' import { Event } from '../../../src/@types/event' @@ -194,4 +195,62 @@ describe('nip42', () => { expect(isCountAuthorized(enabledSettings(), filters, () => new Set([author]))).to.be.false }) }) + + describe('isReconciliationAuthorized', () => { + const noOne = () => new Set() + const onlyAuthor = () => new Set([author]) + + it('allows everything when restricted reads are off', () => { + expect(isReconciliationAuthorized(undefined, {}, noOne)).to.be.true + expect(isReconciliationAuthorized({} as Settings, { kinds: [4] }, noOne)).to.be.true + }) + + it('allows a filter whose kinds are all ordinary', () => { + expect(isReconciliationAuthorized(enabledSettings(), { kinds: [1, 7] }, noOne)).to.be.true + }) + + it('allows a filter that matches no kinds', () => { + expect(isReconciliationAuthorized(enabledSettings(), { kinds: [] }, noOne)).to.be.true + }) + + it('rejects a filter with no kinds, which could match a restricted event', () => { + expect(isReconciliationAuthorized(enabledSettings(), {}, noOne)).to.be.false + expect(isReconciliationAuthorized(enabledSettings(), { authors: [author] }, noOne)).to.be.false + expect(isReconciliationAuthorized(enabledSettings(), { since: 1 }, onlyAuthor)).to.be.false + }) + + it('rejects a filter naming a restricted kind alongside ordinary ones', () => { + expect(isReconciliationAuthorized(enabledSettings(), { kinds: [1, 4] }, noOne)).to.be.false + }) + + it('honours restricted kind ranges', () => { + const settings = enabledSettings([[1059, 1060]]) + + expect(isReconciliationAuthorized(settings, { kinds: [1060] }, noOne)).to.be.false + expect(isReconciliationAuthorized(settings, { kinds: [1061] }, noOne)).to.be.true + }) + + it('allows a possibly-restricted filter scoped to the authenticated pubkey', () => { + expect(isReconciliationAuthorized(enabledSettings(), { authors: [author] }, onlyAuthor)).to.be.true + expect(isReconciliationAuthorized(enabledSettings(), { kinds: [4], '#p': [author] }, onlyAuthor)).to.be.true + }) + + it('rejects a scope that includes a pubkey the client has not authenticated as', () => { + expect(isReconciliationAuthorized(enabledSettings(), { authors: [author, stranger] }, onlyAuthor)).to.be.false + expect(isReconciliationAuthorized(enabledSettings(), { authors: [stranger] }, onlyAuthor)).to.be.false + }) + + it('rejects an empty scope', () => { + expect(isReconciliationAuthorized(enabledSettings(), { authors: [] }, onlyAuthor)).to.be.false + }) + + it('reads the authenticated pubkeys when asked, so lapsed auth is noticed', () => { + const authenticated = new Set([author]) + const filter: SubscriptionFilter = { authors: [author] } + + expect(isReconciliationAuthorized(enabledSettings(), filter, () => authenticated)).to.be.true + authenticated.clear() + expect(isReconciliationAuthorized(enabledSettings(), filter, () => authenticated)).to.be.false + }) + }) }) diff --git a/test/unit/utils/nip66-schedule.spec.ts b/test/unit/utils/nip66-schedule.spec.ts index 522a07751..d7b759f86 100644 --- a/test/unit/utils/nip66-schedule.spec.ts +++ b/test/unit/utils/nip66-schedule.spec.ts @@ -2,7 +2,9 @@ import { expect } from 'chai' import { Settings } from '../../../src/@types/settings' import { + DEFAULT_HISTORY_RETENTION_RUNS, getEffectiveProbeIntervalSeconds, + getNip66HistoryRetention, getProbeIntervalMs, MIN_PROBE_INTERVAL_SECONDS, } from '../../../src/utils/nip66-schedule' @@ -22,4 +24,23 @@ describe('nip66-schedule', () => { expect(getEffectiveProbeIntervalSeconds(settings(120))).to.equal(120) expect(getProbeIntervalMs(settings(120))).to.equal(120_000) }) + + it('derives history retention defaults and overrides', () => { + expect(getNip66HistoryRetention({} as Settings)).to.deep.equal({ + maxRuns: DEFAULT_HISTORY_RETENTION_RUNS, + maxAgeHours: undefined, + }) + + expect( + getNip66HistoryRetention({ + nip66: { historyRetentionRuns: 48, historyRetentionHours: 72 }, + } as Settings), + ).to.deep.equal({ maxRuns: 48, maxAgeHours: 72 }) + + expect( + getNip66HistoryRetention({ + nip66: { historyRetentionHours: 0 }, + } as Settings), + ).to.deep.equal({ maxRuns: DEFAULT_HISTORY_RETENTION_RUNS, maxAgeHours: undefined }) + }) }) diff --git a/test/unit/utils/nip77.spec.ts b/test/unit/utils/nip77.spec.ts new file mode 100644 index 000000000..6def73a28 --- /dev/null +++ b/test/unit/utils/nip77.spec.ts @@ -0,0 +1,59 @@ +import { expect } from 'chai' + +import { + DEFAULT_MAX_EVENTS_PER_SESSION, + DEFAULT_MAX_SESSIONS_PER_CONNECTION, + DEFAULT_SESSION_IDLE_TIMEOUT_MS, + getNip77Limits, + isNip77Active, + NEGENTROPY_FRAME_SIZE_LIMIT, +} from '../../../src/utils/nip77' +import { Settings } from '../../../src/@types/settings' + +describe('NIP-77 helpers', () => { + describe('isNip77Active', () => { + it('is off by default', () => { + expect(isNip77Active({} as Settings)).to.equal(false) + }) + + it('is off when nip77.enabled is false', () => { + expect(isNip77Active({ nip77: { enabled: false } } as Settings)).to.equal(false) + }) + + it('is on when enabled and the event store is postgres', () => { + expect(isNip77Active({ nip77: { enabled: true }, eventStore: { backend: 'postgres' } } as Settings)).to.equal( + true, + ) + }) + + it('treats an unset event store as postgres', () => { + expect(isNip77Active({ nip77: { enabled: true } } as Settings)).to.equal(true) + }) + + it('only accepts a literal true', () => { + expect(isNip77Active({ nip77: { enabled: 'yes' } } as any)).to.equal(false) + }) + }) + + describe('getNip77Limits', () => { + it('falls back to the defaults', () => { + expect(getNip77Limits({} as Settings)).to.deep.equal({ + maxEventsPerSession: DEFAULT_MAX_EVENTS_PER_SESSION, + maxSessionsPerConnection: DEFAULT_MAX_SESSIONS_PER_CONNECTION, + sessionIdleTimeoutMs: DEFAULT_SESSION_IDLE_TIMEOUT_MS, + }) + }) + + it('uses configured values', () => { + expect( + getNip77Limits({ + nip77: { maxEventsPerSession: 10, maxSessionsPerConnection: 2, sessionIdleTimeoutMs: 500 }, + } as Settings), + ).to.deep.equal({ maxEventsPerSession: 10, maxSessionsPerConnection: 2, sessionIdleTimeoutMs: 500 }) + }) + }) + + it('keeps the frame size limit above the 4096 bytes negentropy requires', () => { + expect(NEGENTROPY_FRAME_SIZE_LIMIT).to.be.at.least(4096) + }) +}) diff --git a/test/unit/utils/ops-trust-notifications.spec.ts b/test/unit/utils/ops-trust-notifications.spec.ts new file mode 100644 index 000000000..f22af3858 --- /dev/null +++ b/test/unit/utils/ops-trust-notifications.spec.ts @@ -0,0 +1,53 @@ +import chai from 'chai' +import Sinon from 'sinon' + +import { ICacheAdapter } from '../../../src/@types/adapters' +import { OperatorNotificationEventType } from '../../../src/@types/operator-notifications' +import { notifyProbeRunTransition } from '../../../src/utils/ops-trust-notifications' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const enqueueModule = require('../../../src/utils/operator-notification-enqueue') + +const { expect } = chai + +const createCacheStub = (overrides: Partial = {}): ICacheAdapter => ({ + getKey: async () => '', + hasKey: async () => false, + setKey: async () => true, + setKeyIfNotExists: async () => true, + addToSortedSet: async () => 1, + removeRangeByScoreFromSortedSet: async () => 0, + removeRangeByRankFromSortedSet: async () => 0, + getRangeFromSortedSet: async () => [], + setKeyExpiry: async () => {}, + deleteKey: async () => 1, + getHKey: async () => '', + setHKey: async () => true, + addToSet: async () => 1, + getSetMembers: async () => [], + eval: async () => '', + ...overrides, +}) + +describe('ops-trust-notifications', () => { + afterEach(() => { + Sinon.restore() + }) + + it('enqueues probe failed alerts from atomic transition action', async () => { + const enqueue = Sinon.stub(enqueueModule, 'enqueueOperatorNotification').resolves(true) + const cache = createCacheStub({ + eval: async () => 'failed', + }) + + await notifyProbeRunTransition(cache, { + runAt: new Date().toISOString(), + targets: ['wss://relay.example'], + results: [], + status: 'failed', + }) + + expect(enqueue.calledOnce).to.equal(true) + expect(enqueue.firstCall.args[0]).to.equal(OperatorNotificationEventType.PROBE_FAILED) + }) +}) diff --git a/test/unit/utils/peering-auth-client.spec.ts b/test/unit/utils/peering-auth-client.spec.ts new file mode 100644 index 000000000..efa9cd14b --- /dev/null +++ b/test/unit/utils/peering-auth-client.spec.ts @@ -0,0 +1,159 @@ +import { AddressInfo } from 'node:net' + +import { expect } from 'chai' +import Sinon from 'sinon' +import { WebSocket, WebSocketServer } from 'ws' +import { AuthMessage } from '../../../src/@types/messages' +import { WebSocketAdapterEvent } from '../../../src/constants/adapter' +import { EventKinds, EventTags } from '../../../src/constants/base' +import { AuthMessageHandler } from '../../../src/handlers/auth-message-handler' +import { getPublicKey, isEventIdValid, isEventSignatureValid } from '../../../src/utils/event' +import { authenticateToPeeringRelay } from '../../../src/utils/peering-auth' + +describe('peering-auth client', () => { + const privateKey = 'a'.repeat(64) + const pubkey = getPublicKey(privateKey) + + let server: WebSocketServer | undefined + let client: WebSocket | undefined + + const startServer = async (onConnection: (socket: WebSocket) => void): Promise => { + const created = new WebSocketServer({ port: 0 }) + await new Promise((resolve) => created.once('listening', () => resolve())) + created.on('connection', onConnection) + server = created + const address = created.address() as AddressInfo + + return `ws://127.0.0.1:${address.port}` + } + + afterEach(async () => { + client?.terminate() + client = undefined + + const current = server + server = undefined + if (!current) { + return + } + + await new Promise((resolve) => { + for (const socket of current.clients) { + socket.terminate() + } + current.close(() => resolve()) + }) + }) + + it('authenticates to a relay as this relay keypair', async () => { + const challenge = 'server-challenge' + let received: unknown + + const relayUrl = await startServer((socket) => { + socket.send(JSON.stringify(['AUTH', challenge])) + socket.on('message', (raw) => { + received = JSON.parse(raw.toString()) + const event = (received as [string, { id: string }])[1] + socket.send(JSON.stringify(['OK', event.id, true, ''])) + }) + }) + + const result = await authenticateToPeeringRelay({ relayUrl, privateKey, timeoutMs: 2_000 }) + client = result.socket + + expect(result.challenge).to.equal(challenge) + expect(result.authEvent.pubkey).to.equal(pubkey) + + const message = received as [string, typeof result.authEvent] + expect(message[0]).to.equal('AUTH') + expect(message[1].kind).to.equal(EventKinds.AUTH) + expect(message[1].tags).to.deep.equal([ + [EventTags.AuthRelay, relayUrl], + [EventTags.Challenge, challenge], + ]) + expect(await isEventIdValid(message[1])).to.equal(true) + expect(await isEventSignatureValid(message[1])).to.equal(true) + }) + + it('is recognized as a peer by another nostream relay', async () => { + const challenge = 'nostream-challenge' + let recognized: string | undefined + + const created = new WebSocketServer({ port: 0 }) + await new Promise((resolve) => created.once('listening', () => resolve())) + server = created + const relayUrl = `ws://127.0.0.1:${(created.address() as AddressInfo).port}` + + created.on('connection', (socket) => { + const adapter = { + getClientId: () => 'peer-test', + getChallenge: () => challenge, + addAuthenticatedPubkey: () => true, + setPeerPubkey: (peer: string) => { + recognized = peer + }, + emit: (event: string, payload: unknown) => { + if (event === WebSocketAdapterEvent.Message) { + socket.send(JSON.stringify(payload)) + } + }, + } as any + + const handler = new AuthMessageHandler( + adapter, + () => + ({ + info: { relay_url: relayUrl }, + peering: { + enabled: true, + peers: [{ pubkey, relayUrl: 'wss://peer.example.com', enabled: true }], + }, + }) as any, + ) + + socket.send(JSON.stringify(['AUTH', challenge])) + socket.on('message', (raw) => { + void handler.handleMessage(JSON.parse(raw.toString()) as AuthMessage) + }) + }) + + const result = await authenticateToPeeringRelay({ relayUrl, privateKey, timeoutMs: 2_000 }) + client = result.socket + + expect(result.authEvent.pubkey).to.equal(pubkey) + expect(recognized).to.equal(pubkey) + }) + + it('rejects when the relay refuses the AUTH event', async () => { + const relayUrl = await startServer((socket) => { + socket.send(JSON.stringify(['AUTH', 'server-challenge'])) + socket.on('message', (raw) => { + const event = JSON.parse(raw.toString())[1] as { id: string } + socket.send(JSON.stringify(['OK', event.id, false, 'blocked: peer not allowed'])) + }) + }) + + const error = await authenticateToPeeringRelay({ relayUrl, privateKey, timeoutMs: 2_000 }).then( + () => undefined, + (err: Error) => err, + ) + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.include('peering AUTH rejected') + expect(error?.message).to.include('blocked: peer not allowed') + }) + + it('times out when the relay never sends an AUTH challenge', async () => { + const relayUrl = await startServer(() => { + // Deliberately send nothing. + }) + + const error = await authenticateToPeeringRelay({ relayUrl, privateKey, timeoutMs: 50 }).then( + () => undefined, + (err: Error) => err, + ) + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.include('timed out waiting for peering AUTH') + }) +}) diff --git a/test/unit/utils/peering-auth.spec.ts b/test/unit/utils/peering-auth.spec.ts new file mode 100644 index 000000000..15ea40e44 --- /dev/null +++ b/test/unit/utils/peering-auth.spec.ts @@ -0,0 +1,101 @@ +import { expect } from 'chai' + +import { EventKinds, EventTags } from '../../../src/constants/base' +import { getPublicKey, isEventIdValid, isEventSignatureValid } from '../../../src/utils/event' +import { + buildPeeringAuthEvent, + findConfiguredPeeringPeer, + isConfiguredPeeringPeer, + parsePeeringPeerPubkey, +} from '../../../src/utils/peering-auth' +import { toBech32 } from '../../../src/utils/transform' + +describe('peering-auth identity', () => { + const privateKey = 'a'.repeat(64) + const pubkey = getPublicKey(privateKey) + const otherPubkey = getPublicKey('b'.repeat(64)) + + const settingsWithPeers = (peers: unknown[]) => + ({ + peering: { + enabled: true, + peers, + }, + }) as any + + describe('parsePeeringPeerPubkey', () => { + it('normalizes hex and npub pubkeys to lowercase hex', () => { + expect(parsePeeringPeerPubkey(pubkey)).to.equal(pubkey) + expect(parsePeeringPeerPubkey(pubkey.toUpperCase())).to.equal(pubkey) + expect(parsePeeringPeerPubkey(toBech32('npub')(pubkey))).to.equal(pubkey) + }) + + it('rejects values that are not a 32-byte pubkey', () => { + expect(parsePeeringPeerPubkey('not-a-pubkey')).to.equal(undefined) + expect(parsePeeringPeerPubkey('npub1broken')).to.equal(undefined) + expect(parsePeeringPeerPubkey(42)).to.equal(undefined) + }) + }) + + describe('configured peer lookup', () => { + it('recognizes an enabled configured peer by hex or npub', () => { + const settings = settingsWithPeers([ + { pubkey: toBech32('npub')(pubkey), relayUrl: 'wss://peer.example.com', enabled: true }, + ]) + + expect(isConfiguredPeeringPeer(settings, pubkey)).to.equal(true) + expect(findConfiguredPeeringPeer(settings, pubkey)?.relayUrl).to.equal('wss://peer.example.com') + }) + + it('does not recognize disabled or unconfigured pubkeys', () => { + const settings = settingsWithPeers([ + { pubkey, relayUrl: 'wss://peer.example.com', enabled: false }, + { pubkey: otherPubkey, relayUrl: 'wss://other.example.com' }, + ]) + + expect(isConfiguredPeeringPeer(settings, pubkey)).to.equal(false) + expect(isConfiguredPeeringPeer(settings, getPublicKey('c'.repeat(64)))).to.equal(false) + }) + + it('returns false when peering or the peer list is absent', () => { + expect(isConfiguredPeeringPeer({} as any, pubkey)).to.equal(false) + expect(isConfiguredPeeringPeer({ peering: { enabled: true } } as any, pubkey)).to.equal(false) + }) + }) + + describe('buildPeeringAuthEvent', () => { + it('builds a signed kind 22242 event with relay and challenge tags', async () => { + const event = await buildPeeringAuthEvent({ + relayUrl: 'wss://peer.example.com', + challenge: 'challenge-value', + privateKey, + createdAt: 1_700_000_000, + }) + + expect(event.kind).to.equal(EventKinds.AUTH) + expect(event.pubkey).to.equal(pubkey) + expect(event.created_at).to.equal(1_700_000_000) + expect(event.tags).to.deep.equal([ + [EventTags.AuthRelay, 'wss://peer.example.com'], + [EventTags.Challenge, 'challenge-value'], + ]) + expect(await isEventIdValid(event)).to.equal(true) + expect(await isEventSignatureValid(event)).to.equal(true) + }) + + it('refuses to build an event without a relay URL or challenge', async () => { + for (const options of [ + { relayUrl: '', challenge: 'challenge', privateKey }, + { relayUrl: 'wss://peer.example.com', challenge: ' ', privateKey }, + ]) { + let error: Error | undefined + try { + await buildPeeringAuthEvent(options) + } catch (err) { + error = err as Error + } + expect(error).to.be.instanceOf(Error) + } + }) + }) +}) diff --git a/test/unit/utils/relay-probe-history.spec.ts b/test/unit/utils/relay-probe-history.spec.ts new file mode 100644 index 000000000..80c0c6665 --- /dev/null +++ b/test/unit/utils/relay-probe-history.spec.ts @@ -0,0 +1,106 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) + +const { expect } = chai + +import { RelayProbeRunSnapshot } from '../../../src/@types/relay-probe-snapshot' +import { + RELAY_PROBE_HISTORY_KEY, + RelayProbeHistoryStore, +} from '../../../src/utils/relay-probe-history' +import { serializeProbeResults } from '../../../src/utils/relay-probe-snapshot' +import { ProbeResult } from '../../../src/utils/relay-probe/types' + +describe('relay-probe-history', () => { + let cache: { + addToSortedSet: Sinon.SinonStub + getRangeFromSortedSet: Sinon.SinonStub + removeRangeByRankFromSortedSet: Sinon.SinonStub + removeRangeByScoreFromSortedSet: Sinon.SinonStub + } + let store: RelayProbeHistoryStore + + const sampleResult = (wsStatus: 'ok' | 'error'): ProbeResult => + ({ + target: { + relayUrl: 'wss://relay.example.com', + hostname: 'relay.example.com', + networkType: 'clearnet', + httpOrigin: 'https://relay.example.com', + nip11Url: 'https://relay.example.com/.well-known/nostr.json', + wsUrl: 'wss://relay.example.com', + }, + checkedAt: new Date('2026-01-01T00:00:00.000Z'), + dns: { status: 'ok', durationMs: 1 }, + tls: { status: 'ok', durationMs: 1 }, + wsRtt: { status: wsStatus, durationMs: 1 }, + nip11: { status: 'ok', durationMs: 1 }, + }) as ProbeResult + + beforeEach(() => { + cache = { + addToSortedSet: Sinon.stub().resolves(1), + getRangeFromSortedSet: Sinon.stub().resolves([]), + removeRangeByRankFromSortedSet: Sinon.stub().resolves(0), + removeRangeByScoreFromSortedSet: Sinon.stub().resolves(0), + } + + store = new RelayProbeHistoryStore(cache as any) + }) + + it('appends probe runs to bounded history', async () => { + const snapshot: RelayProbeRunSnapshot = { + runAt: '2026-01-01T00:00:00.000Z', + targets: ['wss://relay.example.com'], + results: serializeProbeResults([sampleResult('ok')]), + status: 'ok', + } + + await store.append(snapshot, { maxRuns: 1 }) + + expect(cache.addToSortedSet).to.have.been.calledOnce + expect(cache.removeRangeByRankFromSortedSet).to.have.been.calledOnceWith( + RELAY_PROBE_HISTORY_KEY, + 0, + -2, + ) + }) + + it('returns the newest history entries up to the requested limit', async () => { + const older: RelayProbeRunSnapshot = { + runAt: '2026-01-01T00:00:00.000Z', + targets: [], + results: [], + status: 'partial', + } + const newer: RelayProbeRunSnapshot = { + runAt: '2026-01-02T00:00:00.000Z', + targets: [], + results: [], + status: 'ok', + } + + cache.getRangeFromSortedSet.resolves([JSON.stringify(older), JSON.stringify(newer)]) + + const history = await store.list(2) + + expect(cache.getRangeFromSortedSet).to.have.been.calledOnceWith(RELAY_PROBE_HISTORY_KEY, -2, -1) + expect(history.map((entry) => entry.runAt)).to.deep.equal([newer.runAt, older.runAt]) + }) + + it('trims expired runs when listing with an age retention limit', async () => { + cache.getRangeFromSortedSet.resolves([]) + + await store.list(10, { maxRuns: 168, maxAgeHours: 24 }) + + expect(cache.removeRangeByScoreFromSortedSet).to.have.been.calledOnce + const [key, minScore, maxScore] = cache.removeRangeByScoreFromSortedSet.firstCall.args + expect(key).to.equal(RELAY_PROBE_HISTORY_KEY) + expect(minScore).to.equal(0) + expect(maxScore).to.be.a('number') + expect(maxScore).to.be.lessThan(Date.now()) + }) +}) diff --git a/test/unit/utils/relay-probe-targets.spec.ts b/test/unit/utils/relay-probe-targets.spec.ts index 4fc27caa2..63bea6295 100644 --- a/test/unit/utils/relay-probe-targets.spec.ts +++ b/test/unit/utils/relay-probe-targets.spec.ts @@ -42,44 +42,16 @@ describe('relay-probe-targets', () => { expect(resolveProbeTargets(settings)).to.deep.equal(['wss://one.example', 'wss://two.example']) }) - it('does not include mirroring.static in public publish targets', () => { + it('does not probe mirroring.static addresses, which are no longer supported', () => { const settings = { ...baseSettings, mirroring: { static: [{ address: 'wss://mirror.example' }], }, - } as Settings + } as unknown as Settings expect(resolvePublicProbeTargets(settings)).to.deep.equal(['wss://relay.example.com']) - }) - - it('includes mirroring.static addresses in addition to configured targets', () => { - const settings = { - ...baseSettings, - nip66: { - ...baseSettings.nip66!, - targets: ['wss://one.example'], - }, - mirroring: { - static: [{ address: 'wss://mirror.example' }, { address: 'wss://one.example' }], - }, - } as Settings - - expect(resolveProbeTargets(settings)).to.deep.equal(['wss://one.example', 'wss://mirror.example']) - }) - - it('includes mirror targets when falling back to info.relay_url', () => { - const settings = { - ...baseSettings, - mirroring: { - static: [{ address: 'wss://mirror.example' }], - }, - } as Settings - - expect(resolveProbeTargets(settings)).to.deep.equal([ - 'wss://relay.example.com', - 'wss://mirror.example', - ]) + expect(resolveProbeTargets(settings)).to.deep.equal(['wss://relay.example.com']) }) it('filters invalid probe targets', () => { diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index d9179f35d..7430b2e39 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'chai' import fs from 'fs' import os from 'os' import path from 'path' +import Sinon from 'sinon' import { ensureSettingsExists, @@ -15,13 +16,17 @@ import { setByPath, validatePathAgainstDefaults, validateSettings, + withSettingsWriteLock, } from '../../../src/utils/settings-config' +import { getSettingsWriteLockPath } from '../../../src/utils/settings-paths' +import { toBech32 } from '../../../src/utils/transform' import { getGuidedSettingCategory, getGuidedSettingField, guidedSettingCategories, requireNonEmptySettingValue, requireSafeNonNegativeIntegerSettingValue, + requireSafePositiveIntegerSettingValue, } from '../../../src/utils/settings-guided-schema' describe('settings-config', () => { @@ -98,6 +103,287 @@ describe('settings-config', () => { expect(issues.some((issue) => issue.path === 'network')).to.equal(true) }) + describe('peering settings', () => { + const withPeering = (peering: unknown) => + ({ + info: { + relay_url: 'wss://relay.example.com', + name: 'Test Relay', + pubkey: 'abcd', + contact: 'test@example.com', + }, + network: { maxPayloadSize: 1024 }, + peering, + }) as any + + const peeringIssues = (peering: unknown) => + validateSettings(withPeering(peering)).filter((issue) => issue.path.startsWith('peering')) + + const settlementFreePeering = { + enabled: true, + contact: 'mailto:peering@relay.example.com', + scopes: [{ kinds: [1, 6, [10000, 19999]], windowSeconds: 3600 }], + terms: [], + } + + it('accepts a settlement-free configuration', () => { + expect(peeringIssues(settlementFreePeering)).to.deep.equal([]) + }) + + it('accepts a paid configuration with absolute and rolling scopes', () => { + expect( + peeringIssues({ + ...settlementFreePeering, + minRequestPowDifficulty: 20, + scopes: [ + { kinds: [1], since: 1700000000, until: 1800000000 }, + { kinds: [3], windowSeconds: 3600 }, + ], + terms: [{ amount: 21000, unit: 'msats', period: 2592000 }], + }), + ).to.deep.equal([]) + }) + + it('accepts a disabled configuration without scopes or contact details', () => { + expect(peeringIssues({ enabled: false })).to.deep.equal([]) + }) + + it('rejects an enabled configuration without scopes', () => { + const issues = peeringIssues({ ...settlementFreePeering, scopes: [] }) + + expect(issues.some((issue) => issue.path === 'peering.scopes')).to.equal(true) + expect(issues[0].message).to.include('at least one scope') + }) + + it('rejects an enabled configuration without contact or endpoint', () => { + const issues = peeringIssues({ ...settlementFreePeering, contact: '', endpoint: '' }) + + expect(issues.some((issue) => issue.path === 'peering.contact')).to.equal(true) + expect(issues[0].message).to.include('contact or endpoint') + }) + + it('rejects an unprefixed endpoint', () => { + const issues = peeringIssues({ ...settlementFreePeering, endpoint: 'relay.example.com' }) + + expect(issues.some((issue) => issue.path === 'peering.endpoint')).to.equal(true) + }) + + it('rejects a contact that is not a URI', () => { + const issues = peeringIssues({ ...settlementFreePeering, contact: 'not-a-uri' }) + + expect(issues.some((issue) => issue.path === 'peering.contact')).to.equal(true) + expect(issues.find((issue) => issue.path === 'peering.contact')?.message).to.include('valid URI') + }) + + it('accepts a mailto contact and ignores a whitespace-only endpoint', () => { + expect(peeringIssues({ ...settlementFreePeering, endpoint: ' ' })).to.deep.equal([]) + }) + + it('rejects an empty kinds list', () => { + const issues = peeringIssues({ ...settlementFreePeering, scopes: [{ kinds: [] }] }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0].kinds')).to.equal(true) + }) + + it('rejects event kinds outside the NIP-01 range', () => { + const issues = peeringIssues({ ...settlementFreePeering, scopes: [{ kinds: [65536] }] }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0].kinds[0]')).to.equal(true) + }) + + it('rejects an inverted kinds range', () => { + const issues = peeringIssues({ ...settlementFreePeering, scopes: [{ kinds: [[100, 1]] }] }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0].kinds[0]')).to.equal(true) + }) + + it('rejects since later than until', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + scopes: [{ kinds: [1], since: 200, until: 100 }], + }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0]')).to.equal(true) + }) + + it('rejects a rolling window combined with since or until', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + scopes: [{ kinds: [1], windowSeconds: 3600, since: 100 }], + }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0]')).to.equal(true) + }) + + it('rejects unknown scope keys', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + scopes: [{ kinds: [1], sinceSeconds: 3600 }], + }) + + expect(issues.some((issue) => issue.path === 'peering.scopes[0].sinceSeconds')).to.equal(true) + }) + + it('rejects malformed terms', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + terms: [{ amount: -1, unit: '', period: 0 }], + }) + + expect(issues.some((issue) => issue.path === 'peering.terms[0].amount')).to.equal(true) + expect(issues.some((issue) => issue.path === 'peering.terms[0].unit')).to.equal(true) + expect(issues.some((issue) => issue.path === 'peering.terms[0].period')).to.equal(true) + }) + + it('rejects unknown term keys', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + terms: [{ amount: 1, unit: 'msats', period: 1, currency: 'btc' }], + }) + + expect(issues.some((issue) => issue.path === 'peering.terms[0].currency')).to.equal(true) + }) + + it('rejects a proof-of-work requirement above the NIP-13 maximum', () => { + const issues = peeringIssues({ ...settlementFreePeering, minRequestPowDifficulty: 257 }) + + expect(issues.some((issue) => issue.path === 'peering.minRequestPowDifficulty')).to.equal(true) + }) + + it('accepts configured peers by hex or npub', () => { + expect( + peeringIssues({ + ...settlementFreePeering, + peers: [ + { pubkey: 'a'.repeat(64), relayUrl: 'wss://peer.example.com' }, + { pubkey: toBech32('npub')('b'.repeat(64)), relayUrl: 'wss://peer-two.example.com', enabled: false }, + ], + }), + ).to.deep.equal([]) + }) + + it('rejects a peer pubkey that is not a 32-byte key', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + peers: [{ pubkey: 'not-a-pubkey', relayUrl: 'wss://peer.example.com' }], + }) + + expect(issues.some((issue) => issue.path === 'peering.peers[0].pubkey')).to.equal(true) + }) + + it('rejects a peer URL that is not ws or wss', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + peers: [{ pubkey: 'a'.repeat(64), relayUrl: 'https://peer.example.com' }], + }) + + expect(issues.some((issue) => issue.path === 'peering.peers[0].relayUrl')).to.equal(true) + }) + + it('rejects the same peer pubkey in hex and npub form', () => { + const pubkey = 'a'.repeat(64) + const issues = peeringIssues({ + ...settlementFreePeering, + peers: [ + { pubkey, relayUrl: 'wss://peer.example.com' }, + { pubkey: toBech32('npub')(pubkey), relayUrl: 'wss://peer.example.com' }, + ], + }) + + expect(issues.some((issue) => issue.path === 'peering.peers[1].pubkey')).to.equal(true) + }) + + it('rejects unknown peer keys and a non-boolean enabled flag', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + peers: [{ pubkey: 'a'.repeat(64), relayUrl: 'wss://peer.example.com', note: 'x', enabled: 'yes' }], + }) + + expect(issues.some((issue) => issue.path === 'peering.peers[0].note')).to.equal(true) + expect(issues.some((issue) => issue.path === 'peering.peers[0].enabled')).to.equal(true) + }) + }) + + it('rejects invalid nip66 history retention settings', () => { + const issues = validateSettings({ + info: { relay_url: 'wss://test.relay', name: 'test' }, + network: {}, + nip66: { historyRetentionRuns: 0, historyRetentionHours: 0 }, + } as any) + + expect(issues.some((issue) => issue.path === 'nip66.historyRetentionRuns')).to.equal(true) + expect(issues.some((issue) => issue.path === 'nip66.historyRetentionHours')).to.equal(false) + }) + + it('rejects non-finite nip66 historyRetentionRuns', () => { + const issues = validateSettings({ + info: { relay_url: 'wss://test.relay', name: 'test' }, + network: {}, + nip66: { historyRetentionRuns: Number.POSITIVE_INFINITY }, + } as any) + + expect(issues.some((issue) => issue.path === 'nip66.historyRetentionRuns')).to.equal(true) + }) + + it('accepts a relay_url with a valid ws/wss/http/https absolute URL', () => { + for (const relay_url of [ + 'ws://nostream.example.com:8008', + 'wss://nostream.example.com', + 'http://localhost:8008', + 'http://nostream.example.com', + 'https://nostream.example.com', + 'https://nostream.example.com:8443', + 'ws://[::1]:8008', + 'wss://127.0.0.1', + ]) { + const issues = validateSettings({ info: { relay_url, name: 'test' } } as any) + + expect( + issues.filter((issue) => issue.path === 'info.relay_url'), + `expected ${relay_url} to pass`, + ).to.be.empty + } + }) + + it('rejects a relay_url that is not a valid absolute URL', () => { + const issues = validateSettings({ info: { relay_url: 'nostream.example.com', name: 'test' } } as any) + + expect( + issues.some( + (issue) => issue.path === 'info.relay_url' && issue.message === 'relay_url must be a valid absolute URL', + ), + ).to.equal(true) + }) + + it('rejects a scheme-only relay_url with no host', () => { + const issues = validateSettings({ info: { relay_url: 'localhost:8008', name: 'test' } } as any) + + expect( + issues.some( + (issue) => issue.path === 'info.relay_url' && issue.message === 'relay_url must include a valid host', + ), + ).to.equal(true) + }) + + it('rejects a relay_url whose protocol is not ws/wss/http/https', () => { + for (const relay_url of [ + 'ftp://nostream.example.com', + 'gopher://nostream.example.com:70', + 'whttp://nostream.example.com', + ]) { + const issues = validateSettings({ info: { relay_url, name: 'test' } } as any) + + expect( + issues.some( + (issue) => + issue.path === 'info.relay_url' && + issue.message === 'relay_url must use ws, wss, http, or https protocol', + ), + `expected ${relay_url} to be rejected for protocol`, + ).to.equal(true) + } + }) + describe('rate limit period validation', () => { const baseSettings = () => ({ @@ -158,6 +444,34 @@ describe('settings-config', () => { expect(issues.some((issue) => issue.path.startsWith('limits.event.rateLimits'))).to.equal(false) }) + // The window is milliseconds everywhere, but 60 is as plausible a number as + // 60000, so the unit cannot be inferred. What can be refused is a value that + // is not a whole count of milliseconds at all: a fraction (0.5 ms), a string + // from a hand-edited YAML, and a value that is not a number. + const invalidPeriodCases: { label: string; period: unknown }[] = [ + { label: 'a fractional period', period: 0.5 }, + { label: 'a string period', period: '60000' }, + { label: 'a NaN period', period: Number.NaN }, + ] + + for (const { label, period } of invalidPeriodCases) { + it(`rejects ${label} on limits.event.rateLimits`, () => { + const settings = baseSettings() + settings.limits = { event: { rateLimits: [{ period, rate: 10 }] } } + + const issues = validateSettings(settings) + expect(issues.some((issue) => issue.path === 'limits.event.rateLimits[0].period')).to.equal(true) + }) + } + + it('names milliseconds in the period message, so a rejected value is not read as seconds', () => { + const settings = baseSettings() + settings.limits = { event: { rateLimits: [{ period: 0.5, rate: 10 }] } } + + const message = validateSettings(settings).find((issue) => issue.path === 'limits.event.rateLimits[0].period')?.message + expect(message).to.contain('milliseconds') + }) + it('rejects a null entry in limits.event.rateLimits without throwing', () => { const settings = baseSettings() settings.limits = { event: { rateLimits: [null] } } @@ -167,6 +481,173 @@ describe('settings-config', () => { }) }) + describe('nip77 settings', () => { + const baseSettings = (nip77: any) => + ({ + info: { relay_url: 'wss://test.relay', name: 'test' }, + network: {}, + nip77, + }) as any + + const nip77Issues = (nip77: any) => + validateSettings(baseSettings(nip77)).filter((issue) => issue.path.startsWith('nip77')) + + it('accepts the shipped defaults', () => { + expect( + nip77Issues({ + enabled: true, + maxEventsPerSession: 100000, + maxSessionsPerConnection: 3, + sessionIdleTimeoutMs: 60000, + }), + ).to.deep.equal([]) + }) + + it('accepts an enabled section that leaves the limits to their defaults', () => { + expect(nip77Issues({ enabled: true })).to.deep.equal([]) + }) + + for (const key of ['maxEventsPerSession', 'maxSessionsPerConnection', 'sessionIdleTimeoutMs']) { + for (const bad of [0, -1, 1.5, '10', Number.NaN]) { + it(`rejects ${key} = ${String(bad)}`, () => { + const issues = nip77Issues({ enabled: true, [key]: bad }) + + expect(issues.map((issue) => issue.path)).to.deep.equal([`nip77.${key}`]) + }) + } + } + + it('does not validate the limits while the feature is off', () => { + expect(nip77Issues({ enabled: false, maxEventsPerSession: 0 })).to.deep.equal([]) + }) + }) + + describe('subscription plan settings', () => { + const settingsWith = (subscriptionPlans: any) => + ({ + info: { relay_url: 'wss://test.relay', name: 'test' }, + network: {}, + payments: { enabled: true, processor: 'lnbits', subscriptionPlans }, + }) as any + + const planIssues = (subscriptionPlans: any) => + validateSettings(settingsWith(subscriptionPlans)).filter((issue) => + issue.path.startsWith('payments.subscriptionPlans'), + ) + + const basic = { id: 'basic', amount: 1000000, intervalSeconds: 2592000, graceSeconds: 259200 } + const premium = { id: 'premium', amount: 5000000, intervalSeconds: 2592000, graceSeconds: 0 } + + it('accepts no plans, an empty list and valid plans', () => { + expect(planIssues(undefined)).to.deep.equal([]) + expect(planIssues([])).to.deep.equal([]) + expect(planIssues([basic, premium])).to.deep.equal([]) + }) + + it('accepts per-plan limits', () => { + const plan = { ...basic, limits: { rateLimits: [{ period: 60000, rate: 120 }], retentionMaxDays: 90 } } + + expect(planIssues([plan])).to.deep.equal([]) + }) + + it('reports a value that is not a list once, through the settings schema', () => { + expect(planIssues({ basic }).map((issue) => issue.path)).to.deep.equal(['payments.subscriptionPlans']) + }) + + it('rejects a plan that is not an object', () => { + expect(planIssues(['basic']).map((issue) => issue.path)).to.deep.equal(['payments.subscriptionPlans[0]']) + }) + + it('rejects a missing, empty, reserved or repeated id', () => { + expect(planIssues([{ ...basic, id: undefined }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].id', + ]) + expect(planIssues([{ ...basic, id: ' ' }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].id', + ]) + expect(planIssues([{ ...basic, id: 'grandfathered' }]).map((issue) => issue.message)).to.deep.equal([ + 'id "grandfathered" is reserved', + ]) + expect(planIssues([basic, { ...premium, id: 'basic' }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[1].id', + ]) + }) + + for (const bad of [0, -1, 1.5, '1000', undefined]) { + it(`rejects amount = ${String(bad)}`, () => { + expect(planIssues([{ ...basic, amount: bad }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].amount', + ]) + }) + + it(`rejects intervalSeconds = ${String(bad)}`, () => { + expect(planIssues([{ ...basic, intervalSeconds: bad }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].intervalSeconds', + ]) + }) + } + + it('rejects two plans with the same amount, since the amount selects the plan', () => { + const issues = planIssues([basic, { ...premium, amount: basic.amount }]) + + expect(issues.map((issue) => issue.path)).to.deep.equal(['payments.subscriptionPlans[1].amount']) + }) + + it('accepts a bigint amount', () => { + expect(planIssues([{ ...basic, amount: 1000000n }])).to.deep.equal([]) + }) + + it('allows a zero grace period but not a negative or fractional one', () => { + expect(planIssues([{ ...basic, graceSeconds: 0 }])).to.deep.equal([]) + for (const bad of [-1, 0.5, '60', undefined]) { + expect(planIssues([{ ...basic, graceSeconds: bad }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].graceSeconds', + ]) + } + }) + + it('rejects invalid per-plan limits', () => { + expect(planIssues([{ ...basic, limits: 'fast' }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].limits', + ]) + expect( + planIssues([{ ...basic, limits: { rateLimits: [{ period: 0, rate: 1 }] } }]).map((issue) => issue.path), + ).to.deep.equal(['payments.subscriptionPlans[0].limits.rateLimits[0].period']) + expect(planIssues([{ ...basic, limits: { rateLimits: 'fast' } }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].limits.rateLimits', + ]) + expect( + planIssues([{ ...basic, limits: { rateLimits: [{ period: 60000, rate: 'fast' }] } }]).map( + (issue) => issue.path, + ), + ).to.deep.equal(['payments.subscriptionPlans[0].limits.rateLimits[0].rate']) + expect( + planIssues([{ ...basic, limits: { rateLimits: [{ period: '60000', rate: 5 }] } }]).map((issue) => issue.path), + ).to.deep.equal(['payments.subscriptionPlans[0].limits.rateLimits[0].period']) + expect( + planIssues([{ ...basic, limits: { rateLimits: [{ period: 60000 }] } }]).map((issue) => issue.path), + ).to.deep.equal(['payments.subscriptionPlans[0].limits.rateLimits[0].rate']) + expect(planIssues([{ ...basic, limits: { rateLimits: ['x'] } }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].limits.rateLimits[0]', + ]) + expect(planIssues([{ ...basic, limits: { retentionMaxDays: 0 } }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].limits.retentionMaxDays', + ]) + }) + + it('reports a problem in each plan', () => { + const issues = planIssues([ + { ...basic, intervalSeconds: 0 }, + { ...premium, graceSeconds: -1 }, + ]) + + expect(issues.map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].intervalSeconds', + 'payments.subscriptionPlans[1].graceSeconds', + ]) + }) + }) + describe('adaptive pow settings', () => { const baseSettings = () => ({ @@ -290,6 +771,39 @@ describe('settings-config', () => { }) }) + describe('removed settings', () => { + const settingsWith = (extra: Record) => + ({ ...loadDefaults(), info: { relay_url: 'wss://relay.example.com', name: 'test' }, ...extra }) as any + + it('accepts a mirroring.static that is still configured, so an unrelated change can be saved', () => { + const settings = settingsWith({ mirroring: { static: [{ address: 'wss://mirror.example', secret: 'x' }] } }) + settings.info.name = 'a new name' + + expect(validateSettings(settings)).to.deep.equal([]) + }) + + it('accepts the mirror.disconnected notification toggle that is still configured', () => { + const settings = settingsWith({}) + settings.admin = { + ...settings.admin, + notifications: { ...settings.admin?.notifications, events: { 'mirror.disconnected': true } }, + } + + expect(validateSettings(settings)).to.deep.equal([]) + }) + + it('still rejects an unknown key at the top level', () => { + expect(validateSettings(settingsWith({ nonsense: true })).map((issue) => issue.path)).to.deep.equal(['nonsense']) + }) + + it('still rejects an unknown key inside a known section', () => { + const settings = settingsWith({}) + settings.limits = { ...settings.limits, nonsense: true } + + expect(validateSettings(settings).map((issue) => issue.path)).to.include('limits.nonsense') + }) + }) + describe('event store settings', () => { const baseSettings = (): any => ({ info: { relay_url: 'wss://test.relay', name: 'test' }, @@ -346,6 +860,26 @@ describe('settings-guided-schema', () => { expect(requireNonEmptySettingValue(' ')).to.equal('Value is required') }) + it('validates guided positive integer fields', () => { + expect(requireSafePositiveIntegerSettingValue('bad')).to.equal('Value must be a positive integer') + expect(requireSafePositiveIntegerSettingValue('-1')).to.equal('Value must be a positive integer') + expect(requireSafePositiveIntegerSettingValue('0')).to.equal('Value must be a positive integer') + expect(requireSafePositiveIntegerSettingValue('9007199254740993')).to.equal('Value must be a safe integer') + expect(requireSafePositiveIntegerSettingValue(' 60000 ')).to.equal(undefined) + }) + + it('exposes the nip77 settings with positive integer validation', () => { + const category = 'nip-features' + + expect(getGuidedSettingField(category, 'nip77.enabled')?.type).to.equal('boolean') + for (const key of ['maxEventsPerSession', 'maxSessionsPerConnection', 'sessionIdleTimeoutMs']) { + const field = getGuidedSettingField(category, `nip77.${key}`) + expect(field?.type, key).to.equal('number') + expect(field?.validate?.('0'), key).to.equal('Value must be a positive integer') + expect(field?.validate?.('3'), key).to.equal(undefined) + } + }) + it('does not create settings.yaml when ensuring config dir exists', () => { const originalConfigDir = process.env.NOSTR_CONFIG_DIR const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-settings-config-')) @@ -404,3 +938,106 @@ describe('settings-guided-schema', () => { } }) }) + +describe('settings write lock', () => { + const originalConfigDir = process.env.NOSTR_CONFIG_DIR + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-settings-lock-')) + process.env.NOSTR_CONFIG_DIR = tmpDir + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + if (originalConfigDir === undefined) { + delete process.env.NOSTR_CONFIG_DIR + } else { + process.env.NOSTR_CONFIG_DIR = originalConfigDir + } + Sinon.restore() + }) + + it('removes the lock file when writing its metadata fails', async () => { + const lockPath = getSettingsWriteLockPath() + Sinon.stub(fs, 'writeFileSync').throws(Object.assign(new Error('no space left on device'), { code: 'ENOSPC' })) + + let error: Error | undefined + try { + await withSettingsWriteLock(() => 'unreachable') + } catch (err) { + error = err as Error + } + + expect(error?.message).to.equal('no space left on device') + expect(fs.existsSync(lockPath)).to.equal(false) + }) + + it('reclaims a lock without metadata once it is older than the grace period', async () => { + ensureSettingsExists() + const lockPath = getSettingsWriteLockPath() + fs.writeFileSync(lockPath, '') + const old = new Date(Date.now() - 61_000) + fs.utimesSync(lockPath, old, old) + + expect(await withSettingsWriteLock(() => 'ran')).to.equal('ran') + expect(fs.existsSync(lockPath)).to.equal(false) + }) + + it('reclaims a lock whose owner pid is gone (ESRCH)', async () => { + ensureSettingsExists() + const lockPath = getSettingsWriteLockPath() + fs.writeFileSync(lockPath, `${process.pid}\n${Date.now()}\n`) + Sinon.stub(process, 'kill').throws(Object.assign(new Error('no such process'), { code: 'ESRCH' })) + + expect(await withSettingsWriteLock(() => 'ran')).to.equal('ran') + expect(fs.existsSync(lockPath)).to.equal(false) + }) + + it('does not steal a live lock when the liveness check fails with EPERM', async () => { + ensureSettingsExists() + const lockPath = getSettingsWriteLockPath() + const acquiredAt = Date.now() + fs.writeFileSync(lockPath, `${process.pid}\n${acquiredAt}\n`) + Sinon.stub(process, 'kill').throws(Object.assign(new Error('not permitted'), { code: 'EPERM' })) + + let now = acquiredAt + Sinon.stub(Date, 'now').callsFake(() => now) + setTimeout(() => { + now = acquiredAt + 31_000 + }, 10) + + let error: Error | undefined + try { + await withSettingsWriteLock(() => 'ran') + } catch (err) { + error = err as Error + } + + expect(error?.message).to.equal('Timed out waiting for settings write lock') + expect(fs.existsSync(lockPath)).to.equal(true) + }) + + it('does not reclaim a fresh lock that has no metadata yet', async () => { + ensureSettingsExists() + const lockPath = getSettingsWriteLockPath() + fs.writeFileSync(lockPath, '') + + const acquiredAt = Date.now() + let now = acquiredAt + Sinon.stub(Date, 'now').callsFake(() => now) + setTimeout(() => { + now = acquiredAt + 31_000 + }, 10) + + let error: Error | undefined + try { + await withSettingsWriteLock(() => 'ran') + } catch (err) { + error = err as Error + } + + expect(error?.message).to.equal('Timed out waiting for settings write lock') + expect(fs.existsSync(lockPath)).to.equal(true) + }) +}) diff --git a/test/unit/utils/settings.spec.ts b/test/unit/utils/settings.spec.ts index 7cbab5f03..72020ae02 100644 --- a/test/unit/utils/settings.spec.ts +++ b/test/unit/utils/settings.spec.ts @@ -277,6 +277,8 @@ describe('SettingsStatic', () => { expect(defaults).to.have.nested.property('nip66.timeouts.nip11Ms', 10_000) expect(defaults).to.have.nested.property('nip66.targets').that.deep.equals([]) expect(defaults).to.have.nested.property('nip66.dnsCacheTtlSeconds', 300) + expect(defaults).to.have.nested.property('nip66.historyRetentionRuns', 168) + expect(defaults).to.have.nested.property('nip66.historyRetentionHours', 0) }) it('user config nip66 block overrides defaults', () => { diff --git a/test/unit/utils/sliding-window-rate-limiter.spec.ts b/test/unit/utils/sliding-window-rate-limiter.spec.ts index a864a93df..cce30d112 100644 --- a/test/unit/utils/sliding-window-rate-limiter.spec.ts +++ b/test/unit/utils/sliding-window-rate-limiter.spec.ts @@ -53,11 +53,12 @@ describe('SlidingWindowRateLimiter', () => { }) it('returns true if rate limited', async () => { - evalStub.resolves(1) + evalStub.resolves('1') const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) - expect(actualResult).to.be.true + expect(actualResult.limited).to.be.true + expect(actualResult.retryAfterMs).to.equal(60000) expect(evalStub).to.have.been.calledOnce const args = evalStub.firstCall.args expect(args[1]).to.deep.equal(['key']) @@ -67,11 +68,11 @@ describe('SlidingWindowRateLimiter', () => { }) it('returns false if not rate limited', async () => { - evalStub.resolves(0) + evalStub.resolves('0') const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) - expect(actualResult).to.be.false + expect(actualResult.limited).to.be.false }) it('robustly handles string return types from Redis', async () => { @@ -79,6 +80,6 @@ describe('SlidingWindowRateLimiter', () => { const actualResult = await rateLimiter.hit('key', 1, { period: 60000, rate: 10 }) - expect(actualResult).to.be.true + expect(actualResult.limited).to.be.true }) }) diff --git a/test/unit/utils/subscription.spec.ts b/test/unit/utils/subscription.spec.ts new file mode 100644 index 000000000..901de7b9e --- /dev/null +++ b/test/unit/utils/subscription.spec.ts @@ -0,0 +1,244 @@ +import { expect } from 'chai' + +import { GRANDFATHERED_PLAN_ID, SubscriptionStatus, UserSubscription } from '../../../src/@types/user-subscription' +import { + applyPayment, + areSubscriptionsEnabled, + endPeriod, + findSubscriptionPlan, + getAccessEndsAt, + hasSubscriptionAccess, +} from '../../../src/utils/subscription' +import { Settings, SubscriptionPlan } from '../../../src/@types/settings' + +const DAY = 86_400_000 +const NOW = new Date('2026-06-15T00:00:00Z') +const at = (offsetMs: number) => new Date(NOW.getTime() + offsetMs) + +const plan: SubscriptionPlan = { + id: 'basic', + amount: 1_000_000n, + intervalSeconds: 30 * 86_400, + graceSeconds: 3 * 86_400, +} + +const subscription = (overrides: Partial = {}): UserSubscription => ({ + pubkey: 'a'.repeat(64), + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: at(-10 * DAY), + currentPeriodEnd: at(20 * DAY), + graceEndsAt: null, + cancelAtPeriodEnd: false, + createdAt: at(-10 * DAY), + updatedAt: at(-10 * DAY), + ...overrides, +}) + +describe('subscription utils', () => { + describe('areSubscriptionsEnabled', () => { + const settings = (payments: unknown) => ({ payments }) as unknown as Settings + + it('is on only when payments are enabled and a plan is configured', () => { + expect(areSubscriptionsEnabled(settings({ enabled: true, subscriptionPlans: [plan] }))).to.equal(true) + expect(areSubscriptionsEnabled(settings({ enabled: false, subscriptionPlans: [plan] }))).to.equal(false) + expect(areSubscriptionsEnabled(settings({ enabled: true, subscriptionPlans: [] }))).to.equal(false) + expect(areSubscriptionsEnabled(settings({ enabled: true }))).to.equal(false) + expect(areSubscriptionsEnabled({} as Settings)).to.equal(false) + }) + }) + + describe('findSubscriptionPlan', () => { + const settings = { payments: { enabled: true, subscriptionPlans: [plan] } } as unknown as Settings + + it('finds a plan by id', () => { + expect(findSubscriptionPlan(settings, 'basic')).to.equal(plan) + }) + + it('returns undefined for an unknown, empty or missing id', () => { + expect(findSubscriptionPlan(settings, 'gold')).to.equal(undefined) + expect(findSubscriptionPlan(settings, '')).to.equal(undefined) + expect(findSubscriptionPlan(settings, null)).to.equal(undefined) + expect(findSubscriptionPlan(settings, undefined)).to.equal(undefined) + }) + }) + + describe('hasSubscriptionAccess', () => { + it('keeps access for a user with no subscription row', () => { + expect(hasSubscriptionAccess(undefined, NOW)).to.equal(true) + }) + + it('keeps access on a subscription that never ends', () => { + const grandfathered = subscription({ planId: GRANDFATHERED_PLAN_ID, currentPeriodEnd: null }) + + expect(hasSubscriptionAccess(grandfathered, NOW)).to.equal(true) + }) + + it('keeps access until the period ends, whatever the sweep has done', () => { + expect(hasSubscriptionAccess(subscription({ currentPeriodEnd: at(1) }), NOW)).to.equal(true) + expect(hasSubscriptionAccess(subscription({ currentPeriodEnd: at(0) }), NOW)).to.equal(false) + expect(hasSubscriptionAccess(subscription({ currentPeriodEnd: at(-1) }), NOW)).to.equal(false) + }) + + it('keeps access while a renewal is pending and the period still runs', () => { + const pending = subscription({ status: SubscriptionStatus.RENEWAL_PENDING, currentPeriodEnd: at(DAY) }) + + expect(hasSubscriptionAccess(pending, NOW)).to.equal(true) + }) + + it('keeps access through the grace period and loses it when grace ends', () => { + const pastDue = (graceEndsAt: Date) => + subscription({ + status: SubscriptionStatus.PAST_DUE, + currentPeriodEnd: at(-2 * DAY), + graceEndsAt, + }) + + expect(hasSubscriptionAccess(pastDue(at(DAY)), NOW)).to.equal(true) + expect(hasSubscriptionAccess(pastDue(at(0)), NOW)).to.equal(false) + }) + + it('loses access once expired, even if the stored period end is in the future', () => { + const expired = subscription({ status: SubscriptionStatus.EXPIRED, currentPeriodEnd: at(20 * DAY) }) + + expect(hasSubscriptionAccess(expired, NOW)).to.equal(false) + }) + + it('keeps access through grace before the sweep has put a lapsed subscription into it', () => { + const lapsed = subscription({ + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: at(-DAY), + graceEndsAt: at(2 * DAY), + }) + + expect(hasSubscriptionAccess(lapsed, NOW)).to.equal(true) + }) + + it('loses access when a lapsed subscription has no grace', () => { + const lapsed = subscription({ status: SubscriptionStatus.ACTIVE, currentPeriodEnd: at(-DAY), graceEndsAt: null }) + + expect(hasSubscriptionAccess(lapsed, NOW)).to.equal(false) + }) + + it('loses access once the grace end has passed, even if the sweep has not run', () => { + const lapsed = subscription({ + status: SubscriptionStatus.RENEWAL_PENDING, + currentPeriodEnd: at(-5 * DAY), + graceEndsAt: at(-DAY), + }) + + expect(hasSubscriptionAccess(lapsed, NOW)).to.equal(false) + }) + }) + + describe('getAccessEndsAt', () => { + it('is the period end, the grace end once past due, and nothing when it never ends', () => { + const periodEnd = at(5 * DAY) + const graceEndsAt = at(8 * DAY) + + expect(getAccessEndsAt(subscription({ currentPeriodEnd: periodEnd }))).to.deep.equal(periodEnd) + expect( + getAccessEndsAt( + subscription({ status: SubscriptionStatus.PAST_DUE, currentPeriodEnd: periodEnd, graceEndsAt }), + ), + ).to.deep.equal(graceEndsAt) + expect(getAccessEndsAt(subscription({ currentPeriodEnd: null }))).to.equal(undefined) + }) + }) + + describe('applyPayment', () => { + const pubkey = 'b'.repeat(64) + + it('starts the first period now', () => { + const result = applyPayment(pubkey, undefined, plan, NOW) + + expect(result).to.deep.equal({ + pubkey, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: NOW, + currentPeriodEnd: at(30 * DAY), + graceEndsAt: at(33 * DAY), + cancelAtPeriodEnd: false, + }) + }) + + it('extends a running period from its end, so paying early loses no time', () => { + const existing = subscription({ currentPeriodStart: at(-25 * DAY), currentPeriodEnd: at(5 * DAY) }) + + const result = applyPayment(pubkey, existing, plan, NOW) + + expect(result.currentPeriodStart).to.deep.equal(at(5 * DAY)) + expect(result.currentPeriodEnd).to.deep.equal(at(35 * DAY)) + expect(result.status).to.equal(SubscriptionStatus.ACTIVE) + }) + + it('extends from the period end while a renewal is pending', () => { + const existing = subscription({ status: SubscriptionStatus.RENEWAL_PENDING, currentPeriodEnd: at(2 * DAY) }) + + expect(applyPayment(pubkey, existing, plan, NOW).currentPeriodStart).to.deep.equal(at(2 * DAY)) + }) + + it('starts now once the period has ended, so the gap is not billed back', () => { + const pastDue = subscription({ + status: SubscriptionStatus.PAST_DUE, + currentPeriodEnd: at(-2 * DAY), + graceEndsAt: at(DAY), + }) + const expired = subscription({ status: SubscriptionStatus.EXPIRED, currentPeriodEnd: at(-40 * DAY) }) + + for (const existing of [pastDue, expired]) { + const result = applyPayment(pubkey, existing, plan, NOW) + + expect(result.currentPeriodStart).to.deep.equal(NOW) + expect(result.graceEndsAt).to.deep.equal(at(33 * DAY)) + expect(result.status).to.equal(SubscriptionStatus.ACTIVE) + } + }) + + it('starts now when moving off the grandfathered plan', () => { + const grandfathered = subscription({ planId: GRANDFATHERED_PLAN_ID, currentPeriodEnd: null }) + + const result = applyPayment(pubkey, grandfathered, plan, NOW) + + expect(result.currentPeriodStart).to.deep.equal(NOW) + expect(result.currentPeriodEnd).to.deep.equal(at(30 * DAY)) + }) + + it('gives a plan with no grace no grace end', () => { + const result = applyPayment(pubkey, undefined, { ...plan, graceSeconds: 0 }, NOW) + + expect(result.graceEndsAt).to.equal(null) + }) + + it('uses the paid plan, so a plan change takes effect on payment', () => { + const premium = { ...plan, id: 'premium', intervalSeconds: 7 * 86_400 } + + const result = applyPayment(pubkey, subscription(), premium, NOW) + + expect(result.planId).to.equal('premium') + expect(result.currentPeriodEnd).to.deep.equal(at(20 * DAY + 7 * DAY)) + }) + }) + + describe('endPeriod', () => { + const ended = subscription({ currentPeriodEnd: at(-DAY) }) + + it('puts the subscription into grace counted from the period end', () => { + expect(endPeriod(ended, plan, NOW)).to.deep.equal({ + status: SubscriptionStatus.PAST_DUE, + graceEndsAt: at(2 * DAY), + }) + }) + + it('expires straight away when the plan has no grace', () => { + expect(endPeriod(ended, { ...plan, graceSeconds: 0 }, NOW).status).to.equal(SubscriptionStatus.EXPIRED) + }) + + it('expires straight away when the sweep was late and grace has already passed', () => { + const longEnded = subscription({ currentPeriodEnd: at(-10 * DAY) }) + + expect(endPeriod(longEnded, plan, NOW)).to.deep.equal({ status: SubscriptionStatus.EXPIRED, graceEndsAt: null }) + }) + }) +}) diff --git a/test/unit/utils/transform.spec.ts b/test/unit/utils/transform.spec.ts index f8dd80883..ac815329d 100644 --- a/test/unit/utils/transform.spec.ts +++ b/test/unit/utils/transform.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai' -import { fromBuffer, toBuffer, toJSON } from '../../../src/utils/transform' +import { fromBech32, fromBuffer, fromNpub, toBech32, toBuffer, toJSON } from '../../../src/utils/transform' describe('toJSON', () => { it('returns given value JSON stringified', () => { @@ -19,3 +19,36 @@ describe('fromBuffer', () => { expect(fromBuffer(Buffer.from('aa55', 'hex'))).to.equal('aa55') }) }) + +describe('fromNpub', () => { + const hex = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + + it('decodes an npub of 32 bytes to its hex', () => { + expect(fromNpub(toBech32('npub')(hex))).to.equal(hex) + }) + + it('accepts an npub in upper case', () => { + expect(fromNpub(toBech32('npub')(hex).toUpperCase())).to.equal(hex) + }) + + it('rejects an npub of fewer or more than 32 bytes, which fromBech32 would cut or accept', () => { + expect(() => fromNpub(toBech32('npub')('a'.repeat(62)))).to.throw('32 bytes') + expect(() => fromNpub(toBech32('npub')('a'.repeat(66)))).to.throw('32 bytes') + expect(fromBech32(toBech32('npub')('a'.repeat(66)))).to.have.length(64) + }) + + it('rejects another prefix', () => { + expect(() => fromNpub(toBech32('nsec')(hex))).to.throw('Expected an npub') + }) + + it('rejects mixed case', () => { + expect(() => fromNpub(toBech32('npub')(hex).replace('n', 'N'))).to.throw('mixed-case') + }) + + it('rejects a bad checksum and text that is not bech32', () => { + const npub = toBech32('npub')(hex) + + expect(() => fromNpub(`${npub.slice(0, -1)}${npub.endsWith('q') ? 'p' : 'q'}`)).to.throw() + expect(() => fromNpub('not an npub')).to.throw() + }) +}) diff --git a/website/hero-animation/README.md b/website/hero-animation/README.md index 03505e3e3..073b67cd6 100644 --- a/website/hero-animation/README.md +++ b/website/hero-animation/README.md @@ -32,8 +32,17 @@ pnpm run preview ## Export MP4 +NIP counts in the film (hero stats, features slide, relay-core checklist) come from the repo root `package.json` → `supportedNips` via `src/repo-meta.ts`. After changing supported NIPs, re-render and refresh site media: + +```bash +pnpm run render # → output/nostream-hero.mp4 + output/film.meta.json +pnpm run frames # poster frame for the website +cd ../site && pnpm run media +``` + +`pnpm run render` writes `output/film.meta.json` (`nipCount`). The Astro site copies that to `public/media/hero.meta.json` and `pnpm build` runs `check-hero-nips.mjs` so a stale hero video fails CI until you re-export. + ```bash -pnpm run render # → output/nostream-hero.mp4 (very high bitrate, ~45 MB) pnpm run render:small # → output/nostream-hero-low.mp4 (~4 MB, for sharing / PR previews) ``` @@ -44,7 +53,7 @@ pnpm run frames # → output/frames/f0130.png … ``` -## Release film — nostream 3.3.0 +## Release film — nostream 3.4.0 A second, operator-focused cut in the same visual dialect: **40 s** at 1920×1080, 30 fps = **1200 frames**, composed in `src/release-film.ts` and rendered headlessly by `src/release-render.ts`. @@ -54,14 +63,54 @@ A second, operator-focused cut in the same visual dialect: **40 s** at 1920×108 4. **Close** (760–1200): sign-off over the particle planet ```bash -pnpm run render:release # → output/release/nostream-3.3.0-very_high.mp4 (~14 MB) -pnpm run render:release:small # → output/release/nostream-3.3.0-low.mp4 (~2.3 MB, for sharing) +pnpm run render:release # → output/release/nostream-3.4.0-very_high.mp4 (~14 MB) +pnpm run render:release:small # → output/release/nostream-3.4.0-low.mp4 (~2.3 MB, for sharing) pnpm run frames:release # → output/release/frames/f0060.png … (one still per scene + the end card) ``` +## Peering launch film + +A 40 s cut for relay peering ([#942](https://github.com/cameri/nostream/issues/942)), in the same dialect as the 3.3.0 release film: **1200 frames** at 1920×1080, composed in `src/peering-film.ts` and rendered by `src/peering-render.ts`. Scene copy and claim traceability live in [`docs/peering-film-copy.md`](docs/peering-film-copy.md) — draft that before changing on-screen text. + +1. **People** (frames 0–240): run a relay for your people, not the whole network — peers for its scope, fetches on demand. +2. **Worth** (240–520): your archive is worth something now; sell feeds in sats; flat per-period terms, never per event. +3. **Fast** (520–760): cache peers and an operator-configured window (e.g. the last hour); hot feed near readers. +4. **Middle** (760–980): relays that only talk to relays — archive/cache infrastructure, edges facing users. +5. **Close** (980–1200): today every relay tries to be everything → one thing well; the network assembles the rest. + +```bash +pnpm run render:peering # → output/peering/nostream-peering.mp4 +pnpm run render:peering:small # → output/peering/nostream-peering-low.mp4 +pnpm run frames:peering # → output/peering/frames/f0150.png … (one still per scene + the end card) +cd ../site && pnpm run media # → public/media/peering.mp4, peering-poster.webp (home #peering) +``` + +## Admin console film + +**35 s** at 1920×1080. Opens on “Operator Console”, then each feature is a short explanation on the landing-hero backdrop (dot grid, planet, sparkle halo) followed by that screenshot full frame: metrics, settings, NIP-66, notifications. + +1. **Title** — “Operator Console” +2. **Metrics** — explanation, then `metrices.png` full frame +3. **Settings** — explanation, then `settings-editor.png` full frame +4. **NIP-66** — explanation, then `nip66.png` full frame +5. **Alerts** — explanation, then `notification.png` full frame +6. **Close** — back on the hero backdrop + +```bash +pnpm run preview:admin # live preview +pnpm run render:admin # → output/admin/nostream-admin.mp4 +pnpm run render:admin:small # → output/admin/nostream-admin-low.mp4 +pnpm run frames:admin # stills for review / posters +``` + +After changing the admin film, run `pnpm run media` in `website/site` to re-encode `public/media/admin.mp4` (Cloudflare Workers assets must stay under 25 MiB — do not copy the raw Framefields mp4 into `public/`). + ## Edit the story - Timeline & layout: `src/film.ts` (scene windows `HERO`, `WHAT`, `FLOW`, `FEATURES`, `DEPLOY`) - Release film: `src/release-film.ts` (scene windows `PROBLEM`, `WAIT`, `DOCS`, `CLOSE`), rendered by `src/release-render.ts` +- Peering film: `src/peering-film.ts` (scene windows `PEOPLE`, `WORTH`, `FAST`, `MIDDLE`, `CLOSE`), rendered by `src/peering-render.ts` +- Admin film: `src/admin-film.ts`, rendered by `src/admin-render.ts` - Palette & fonts: `src/theme.ts` - Logos / planet / grid: `src/build-assets.ts` (source SVGs in `assets/icons-raw/`, Nostr PNG in `assets/logos/`) +- Admin screenshots: `website/site/src/assets/screens/*.png` diff --git a/website/hero-animation/docs/peering-film-copy.md b/website/hero-animation/docs/peering-film-copy.md new file mode 100644 index 000000000..d7913f1f4 --- /dev/null +++ b/website/hero-animation/docs/peering-film-copy.md @@ -0,0 +1,109 @@ +# Relay peering launch film — scene copy + +Source: [nostream#942](https://github.com/cameri/nostream/issues/942). Composition: `src/peering-film.ts`. +40 s · 1920×1080 · 30 fps · 1200 frames. One job per scene; jobs-to-be-done only (no protocol tutorial). + +**Approval:** Draft for maintainer review before the peering release ships on the site. + +## Factual guardrails (#931, #932–#940) + +| Claim on screen | Spec / issue | +|---|---| +| **Flat or traffic-based** fees for a **period** the operator sets | #931 paid peering terms (`amount`, `unit`, `period` in NIP-11) | +| **Never per event** | #931 out of scope: no per-event payments | +| **Lightning**, **sats**, **between relays** | #931 / #937 settlement between peer relays | +| **Cache window** operator-configured; “the last hour” is an **example** | #931 cache peers + rolling-window scope in NIP-11 | +| **Ask an archive** outside the window | #931 cache miss must not imply “event does not exist” | +| **Sell feeds** to other relays | #931 archive/backbone peers; paid terms | +| No operator names, adoption stats, or network metrics | #942 production notes | + +--- + +## Scene 1 · PEOPLE (frames 0–240) + +**Beat:** Run a relay for your people, not the whole network. + +| Element | Copy | +|---|---| +| Kicker | `01 — YOUR PEOPLE` | +| Headline | Run a relay for your people. | +| Accent | Not the whole network. | +| Card 1 | **SCOPE** · Your people · the people you follow | +| Card 2 | **PEERING** · Their relays · the ones your people use | +| Card 3 | **FETCHES** · The rest · on demand | +| Payoff | Feel complete without ingesting everything. | + +**Issue gloss:** Community relay peers for its scope and fetches the rest on demand. + +--- + +## Scene 2 · WORTH (frames 240–520) + +**Beat:** Your archive is worth something now. + +| Element | Copy | +|---|---| +| Kicker | `02 — YOUR ARCHIVE` | +| Headline | Your archive / is worth something now. | +| Sub | Sell feeds to other relays — settled in sats. | +| Panel | PEERING TERMS · **PER PERIOD** chip | +| Terms | fee: flat or traffic-based — you set the terms · period: you set the length · settlement: Lightning, in sats, between relays · per event: **never** | +| Payoff | A product, not a cost center. | + +**Issue gloss:** Deep history can be sold to other relays; completeness becomes a product. + +--- + +## Scene 3 · FAST (frames 520–760) + +**Beat:** Fast, everywhere. + +| Element | Copy | +|---|---| +| Kicker | `03 — HOT FEED` | +| Headline | Fast, everywhere. | +| Sub | The hot feed, near the readers. | +| Track | OLDER THAN THE WINDOW · THE HOT FEED | +| Note A | BEFORE THIS CUT · Ask an archive. · outside the window | +| Note B | CACHE PEERS · A rolling window · the last hour, or whatever you configure | +| Payoff | Small relays feel instant — without owning the firehose. | + +**Issue gloss:** Cache peers holding only the last hour put hot feed near readers. + +--- + +## Scene 4 · MIDDLE (frames 760–980) + +**Beat:** Relays that only talk to relays. + +| Element | Copy | +|---|---| +| Kicker | `04 — THE MIDDLE` | +| Headline | Relays that only talk to relays. | +| Accent | The network can grow a middle. | +| Archive | Deep history · backbone infrastructure | +| Cache | Rolling window · hot feed near readers | +| Edge | **EDGE** · Readers connect · the relay readers connect to | +| Payoff | Infrastructure behind. Users in front. | + +**Issue gloss:** Archives and caches as infrastructure; edge relays facing readers. + +--- + +## Scene 5 · CLOSE (frames 980–1200) + +**Beat:** Inversion — one thing well; the network assembles the rest. + +| Element | Copy | +|---|---| +| Contrast | Today, every relay tries to be everything. | +| Headline | Each relay gets to be one thing well. | +| Accent | The network assembles the rest. | +| Lockup | [nostream mark] nostream relay peering | + +--- + +## Site + +After render: `pnpm run media` in `website/site` → `public/media/peering.mp4`, `peering-poster.webp`. +Home section: `#peering` (`Peering.astro`). diff --git a/website/hero-animation/package.json b/website/hero-animation/package.json index 0c008bc52..107158711 100644 --- a/website/hero-animation/package.json +++ b/website/hero-animation/package.json @@ -16,6 +16,13 @@ "render:release": "tsx src/release-render.ts video very_high", "render:release:small": "tsx src/release-render.ts video low", "frames:release": "tsx src/release-render.ts frames 60 300 650 1000 1199", + "render:peering": "tsx src/peering-render.ts video very_high", + "render:peering:small": "tsx src/peering-render.ts video low", + "frames:peering": "tsx src/peering-render.ts frames 150 470 680 940 1100 1199", + "preview:admin": "tsx src/admin-render.ts preview --open", + "render:admin": "tsx src/admin-render.ts video very_high", + "render:admin:small": "tsx src/admin-render.ts video low", + "frames:admin": "tsx src/admin-render.ts frames 50 140 250 450 670 860 1040", "typecheck": "tsc -p ." }, "engines": { @@ -31,6 +38,9 @@ "typescript": "^5.8.3" }, "pnpm": { + "overrides": { + "ip@<=2.0.1": "npm:@stdlib/utils-noop@^0.2.3" + }, "onlyBuiltDependencies": [ "esbuild", "node-av", diff --git a/website/hero-animation/pnpm-lock.yaml b/website/hero-animation/pnpm-lock.yaml index 15b070c38..84d131f22 100644 --- a/website/hero-animation/pnpm-lock.yaml +++ b/website/hero-animation/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + ip@<=2.0.1: npm:@stdlib/utils-noop@^0.2.3 + importers: .: @@ -647,6 +650,11 @@ packages: '@shinyoshiaki/jspack@0.0.6': resolution: {integrity: sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg==} + '@stdlib/utils-noop@0.2.3': + resolution: {integrity: sha512-kZQWwUwq+RwmKBZZD+6dnRwhjfut5fBgbEappugvVjFGp12wDKXCj6D6Y/xTmBdKFRumO8/gzkj9n6DKNDchpA==} + engines: {node: '>=0.10.0', npm: '>2.7.0'} + os: [aix, darwin, freebsd, linux, macos, openbsd, sunos, win32, windows] + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -871,9 +879,6 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip@2.0.1: - resolution: {integrity: sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==} - is-plain-object@2.0.4: resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} engines: {node: '>=0.10.0'} @@ -1580,6 +1585,9 @@ snapshots: '@shinyoshiaki/jspack@0.0.6': optional: true + '@stdlib/utils-noop@0.2.3': + optional: true + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -1841,9 +1849,6 @@ snapshots: internmap@2.0.3: {} - ip@2.0.1: - optional: true - is-plain-object@2.0.4: dependencies: isobject: 3.0.1 @@ -2161,7 +2166,7 @@ snapshots: debug: 4.4.0 fast-deep-equal: 3.1.3 int64-buffer: 1.1.0 - ip: 2.0.1 + ip: '@stdlib/utils-noop@0.2.3' mp4box: 0.5.4 multicast-dns: 7.2.5 tweetnacl: 1.0.3 diff --git a/website/hero-animation/src/admin-film.ts b/website/hero-animation/src/admin-film.ts new file mode 100644 index 000000000..21ec3db9f --- /dev/null +++ b/website/hero-animation/src/admin-film.ts @@ -0,0 +1,366 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { Composition, Layer, LayerAnimation, Vignette } from 'framefields' +import { + BG, + BODY, + FG, + FONT_MONO, + FONT_SANS, + FPS, + H, + MUTED, + PINK, + TEAL, + W, +} from './theme.js' + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const SITE_SCREENS = path.join(ROOT, '..', 'site', 'src', 'assets', 'screens') +const SITE_LOGO = path.join(ROOT, '..', 'site', 'src', 'assets', 'logo.png') +const asset = (name: string) => path.join(ROOT, 'assets', 'gen', `${name}.png`) +const screen = (file: string) => path.join(SITE_SCREENS, file) + +/** + * Admin console film. + * Talk beats use the landing-hero backdrop (dot grid, planet, sparkle halo). + * Each beat is a short explanation, then that screen full-frame. + * 35.3 s @ 30 fps. + */ +export const DURATION_ADMIN = 1060 + +type Node = + | ReturnType + | ReturnType + | ReturnType + | ReturnType +type Key = [number, number] | [number, number, string] +type Window = [number, number] + +const LAST = DURATION_ADMIN - 1 + +const TITLE = { start: 0, end: 100 } +const METRICS_SAY = { start: 100, end: 190 } +const METRICS_SEE = { start: 190, end: 310 } +const SETTINGS_SAY = { start: 310, end: 400 } +const SETTINGS_SEE = { start: 400, end: 520 } +const NIP_SAY = { start: 520, end: 610 } +const NIP_SEE = { start: 610, end: 730 } +const NOTE_SAY = { start: 730, end: 820 } +const NOTE_SEE = { start: 820, end: 940 } +const CLOSE = { start: 940, end: DURATION_ADMIN } + +const TALK: Window[] = [ + [TITLE.start, TITLE.end], + [METRICS_SAY.start, METRICS_SAY.end], + [SETTINGS_SAY.start, SETTINGS_SAY.end], + [NIP_SAY.start, NIP_SAY.end], + [NOTE_SAY.start, NOTE_SAY.end], + [CLOSE.start, CLOSE.end], +] + +const SHOT = { + metrices: { w: 2046, h: 1434, file: 'metrices.png' }, + nip66: { w: 2102, h: 1294, file: 'nip66.png' }, + settingsEditor: { w: 2046, h: 906, file: 'settings-editor.png' }, + notification: { w: 2366, h: 1476, file: 'notification.png' }, +} as const + +type ShotKey = keyof typeof SHOT + +function mergeWindows(windows: Window[], gap: number): Window[] { + const sorted = [...windows].sort((a, b) => a[0] - b[0]) + const out: Window[] = [] + for (const w of sorted) { + const prev = out[out.length - 1] + if (prev && w[0] <= prev[1] + gap) prev[1] = Math.max(prev[1], w[1]) + else out.push([...w]) + } + return out +} + +function windowKeys(windows: Window[], peak = 1, inF = 14, outF = 14): Key[] { + const keys: Key[] = [] + let last = -1 + const push = (f: number, v: number, ease?: string) => { + const frame = Math.max(0, Math.min(LAST, Math.round(f))) + if (frame <= last) return + keys.push(ease ? [frame, v, ease] : [frame, v]) + last = frame + } + const merged = mergeWindows(windows, inF + outF) + if (merged[0][0] > 0) push(0, 0) + for (const [s, e] of merged) { + push(s, 0) + push(s + inF, peak, 'power2.out') + if (e < DURATION_ADMIN) { + push(e - outF, peak) + push(e, 0, 'power2.in') + } + } + return keys +} + +interface Show { + start: number + end?: number + dy?: number + inF?: number + outF?: number +} + +function show(y: number, { start, end = DURATION_ADMIN, dy = 0, inF = 14, outF = 12 }: Show) { + const anim = LayerAnimation.create().keys('opacity', windowKeys([[start, end]], 1, inF, outF)) + if (dy) { + const yk: Key[] = [ + [start, y + dy], + [Math.min(LAST, start + inF + 8), y, 'power3.out'], + ] + if (end < DURATION_ADMIN) { + yk.push([Math.max(start + inF + 10, end - outF), y]) + yk.push([end, y - dy * 0.45, 'power2.in']) + } + anim.keys('y', yk) + } + return anim +} + +const mono = (text: string, x: number, y: number, size: number, opts: Record = {}) => + Layer.text(text, { + position: 'absolute', + x, + y, + fontFamily: FONT_MONO, + fontSize: size, + fill: BODY, + opacity: 0, + ...opts, + }) + +const sans = (text: string, x: number, y: number, size: number, opts: Record = {}) => + Layer.text(text, { + position: 'absolute', + x, + y, + fontFamily: FONT_SANS, + fontSize: size, + fontWeight: 800, + fill: FG, + letterSpacing: -2, + opacity: 0, + ...opts, + }) + +/** Entire screenshot, inset so a frame and bloom can sit around it. */ +function contain(name: ShotKey, maxW: number, maxH: number) { + const { w, h } = SHOT[name] + const s = Math.min(maxW / w, maxH / h) + const dw = Math.round(w * s) + const dh = Math.round(h * s) + return { w: dw, h: dh } +} + +const rect = (x: number, y: number, w: number, h: number, opts: Record = {}) => + Layer.box({ position: 'absolute', x, y, width: w, height: h, opacity: 0, ...opts }) + +export async function buildFilm(): Promise { + const pending: { node: Node; anim?: LayerAnimation }[] = [] + const add = (node: Node, anim?: LayerAnimation) => pending.push({ node, anim }) + + const hx = 160 + + // ── Same backdrop as the landing hero ──────────────────────────────────── + add( + Layer.image(asset('grid'), { position: 'absolute', x: 0, y: 0, width: W, height: H, opacity: 0 }), + LayerAnimation.create().keys('opacity', windowKeys(TALK, 1, 16, 12)), + ) + + const planetSize = 1700 + const haloSize = planetSize * (2000 / 1600) + const planetX = 1000 + const planetY: Key[] = [ + [0, 1100], + [48, 520, 'power3.out'], + [LAST, 500], + ] + add( + Layer.image(asset('planet'), { + position: 'absolute', + x: planetX, + y: 1100, + width: planetSize, + height: planetSize, + opacity: 0, + }), + LayerAnimation.create() + .keys('y', planetY) + .keys('opacity', windowKeys(TALK, 1, 16, 12)) + .fromTo('rotation', 0, 42, { start: 0, end: LAST, ease: 'linear' }), + ) + const haloOffset = (haloSize - planetSize) / 2 + add( + Layer.image(asset('sparkles'), { + position: 'absolute', + x: planetX - haloOffset, + y: 1100 - haloOffset, + width: haloSize, + height: haloSize, + opacity: 0, + }), + LayerAnimation.create() + .keys( + 'y', + planetY.map(([f, v, e]) => (e ? [f, (v as number) - haloOffset, e] : [f, (v as number) - haloOffset]) as Key), + ) + .keys('opacity', windowKeys(TALK, 1, 16, 12)) + .fromTo('rotation', 8, -28, { start: 0, end: LAST, ease: 'linear' }), + ) + + add( + Layer.image(SITE_LOGO, { position: 'absolute', x: 118, y: 41, width: 24, height: 24, opacity: 0 }), + LayerAnimation.create().keys('opacity', windowKeys(TALK, 1, 12, 10)), + ) + add( + mono('nostream', 150, 37, 22, { fill: FG, fontWeight: 700 }), + LayerAnimation.create().keys('opacity', windowKeys(TALK, 1, 12, 10)), + ) + + const explain = (kicker: string, head: string, accent: string, body: string, win: { start: number; end: number }) => { + add(mono(kicker, hx, 250, 15, { letterSpacing: 3, fill: TEAL }), show(250, { start: win.start + 4, end: win.end, dy: 10 })) + add( + rect(hx, 276, 64, 1, { background: TEAL }), + LayerAnimation.create() + .keys('opacity', windowKeys([[win.start + 8, win.end]], 0.85, 10, 10)) + .keys('width', [ + [win.start + 8, 0], + [win.start + 28, 64, 'power2.out'], + [Math.max(win.start + 30, win.end - 12), 64], + [win.end, 0], + ]), + ) + add(sans(head, hx, 300, 78), show(300, { start: win.start + 10, end: win.end, dy: 28, inF: 16 })) + add(sans(accent, hx, 388, 78, { fill: PINK }), show(388, { start: win.start + 20, end: win.end, dy: 24, inF: 16 })) + add(mono(body, hx, 500, 22, { fill: BODY }), show(500, { start: win.start + 36, end: win.end, dy: 12 })) + } + + /** + * Screenshot and its hairline share one box. The rule is a dim 1px edge, + * not a bright stroke that can drift off the bitmap. + */ + const fullShot = (name: ShotKey, label: string, win: { start: number; end: number }) => { + const stroke = 1 + const marginX = 72 + const marginY = 64 + const inner = contain(name, W - marginX * 2, H - marginY * 2 - 28) + const x = Math.round((W - inner.w) / 2) + const y = Math.round((H - inner.h) / 2 - 8) + const fade = () => LayerAnimation.create().keys('opacity', windowKeys([[win.start, win.end]], 1, 14, 12)) + + add( + rect(x - stroke, y - stroke, inner.w + stroke * 2, inner.h + stroke * 2, { + background: BG, + borderColor: '#6e6e78', + borderWidth: stroke, + }), + LayerAnimation.create().keys('opacity', windowKeys([[win.start, win.end]], 0.85, 14, 12)), + ) + + add( + Layer.image(screen(SHOT[name].file), { + position: 'absolute', + x, + y, + width: inner.w, + height: inner.h, + opacity: 0, + }), + fade(), + ) + + add( + mono(label, 0, y + inner.h + 16, 14, { fill: MUTED, letterSpacing: 3, width: W, align: 'center' }), + show(y + inner.h + 16, { start: win.start + 16, end: win.end, dy: 8, inF: 12, outF: 10 }), + ) + } + + // ── Open ───────────────────────────────────────────────────────────────── + add( + sans('Operator', 0, 380, 96, { width: W, align: 'center', letterSpacing: -3 }), + show(380, { start: 12, end: TITLE.end, dy: 28, inF: 18 }), + ) + add( + sans('Console', 0, 484, 96, { width: W, align: 'center', letterSpacing: -3, fill: PINK }), + show(484, { start: 24, end: TITLE.end, dy: 24, inF: 18 }), + ) + add( + mono('Know what your relay is actually doing.', 0, 610, 24, { fill: MUTED, width: W, align: 'center' }), + show(610, { start: 48, end: TITLE.end, dy: 12 }), + ) + + // ── Metrics ────────────────────────────────────────────────────────────── + explain( + 'LIVE METRICS', + 'Watch the load', + 'as it happens.', + 'Events, rejections, connections, CPU and memory — over SSE.', + METRICS_SAY, + ) + fullShot('metrices', '/ADMIN · LIVE METRICS', METRICS_SEE) + + // ── Settings ───────────────────────────────────────────────────────────── + explain( + 'SETTINGS', + 'Change it.', + 'Undo it.', + 'Validated edits, automatic backups, one-click restore.', + SETTINGS_SAY, + ) + fullShot('settingsEditor', '/ADMIN · SETTINGS', SETTINGS_SEE) + + // ── NIP-66 ─────────────────────────────────────────────────────────────── + explain( + 'NIP-66', + 'Reachable from', + 'the outside.', + 'DNS, TLS, WebSocket RTT and NIP-11 — probed per target.', + NIP_SAY, + ) + fullShot('nip66', '/ADMIN · NIP-66', NIP_SEE) + + // ── Notifications ──────────────────────────────────────────────────────── + explain( + 'ALERTS', + 'Hear about trouble', + 'first.', + 'Outbound webhooks, and a log of every delivery.', + NOTE_SAY, + ) + fullShot('notification', '/ADMIN · ALERTS', NOTE_SEE) + + // ── Close, back on the hero backdrop ───────────────────────────────────── + add( + sans('Built into nostream.', 0, 430, 72, { width: W, align: 'center', letterSpacing: -2 }), + show(430, { start: CLOSE.start + 16, dy: 22, inF: 18 }), + ) + add( + mono('nostream start → /admin', 0, 540, 26, { fill: TEAL, width: W, align: 'center' }), + show(540, { start: CLOSE.start + 40, dy: 12, inF: 16 }), + ) + + const film = new Composition({ + width: W, + height: H, + fps: FPS, + durationFrames: DURATION_ADMIN, + backgroundColor: BG, + fonts: [path.join(ROOT, 'assets/fonts/JetBrainsMono.ttf'), path.join(ROOT, 'assets/fonts/Inter.ttf')], + }) + for (const { node, anim } of pending) film.add((anim ? node.animate(anim) : node) as never) + film.add( + Layer.image(asset('grain'), { position: 'absolute', x: 0, y: 0, width: W, height: H, opacity: 0 }).animate( + LayerAnimation.create().keys('opacity', windowKeys(TALK, 0.85, 12, 10)), + ) as never, + ) + film.apply(new Vignette({ strength: 0.16, radius: 0.95, softness: 0.7 })) + return film +} diff --git a/website/hero-animation/src/admin-render.ts b/website/hero-animation/src/admin-render.ts new file mode 100644 index 000000000..f5af5a885 --- /dev/null +++ b/website/hero-animation/src/admin-render.ts @@ -0,0 +1,44 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { HeadlessMediaRenderer, startPreview } from 'framefields' +import { buildFilm } from './admin-film.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const OUTPUT = path.join(__dirname, '..', 'output', 'admin') + +const [mode = 'preview', ...args] = process.argv.slice(2) + +if (mode === 'preview') { + const session = await startPreview( + { entry: new URL('./admin-film.ts', import.meta.url), export: 'buildFilm' }, + { title: 'nostream admin console', open: args.includes('--open') }, + ) + console.log(`\nPreview running at:\n ${session.url}\n`) + console.log('Leave the tab open while reviewing. Press Ctrl+C here to stop the server.\n') + await session.closed + process.exit(0) +} + +const film = await buildFilm() +await fs.mkdir(path.join(OUTPUT, 'frames'), { recursive: true }) + +if (mode === 'frames') { + const renderer = new HeadlessMediaRenderer() + for (const frame of args.map(Number)) { + const file = path.join(OUTPUT, 'frames', `f${String(frame).padStart(4, '0')}.png`) + await fs.writeFile(file, await film.renderFrame({ frame, renderer })) + console.log(file) + } + process.exit(0) +} + +const quality = args[0] ?? 'very_high' +const started = Date.now() +const result = await film.renderVideo({ + outputPath: path.join(OUTPUT, quality === 'very_high' ? 'nostream-admin.mp4' : `nostream-admin-${quality}.mp4`), + quality, +}) +await result.cleanup?.().catch(() => {}) +console.log(`${result.filePath} (${((Date.now() - started) / 1000).toFixed(1)} s)`) +process.exit(0) diff --git a/website/hero-animation/src/build-assets.ts b/website/hero-animation/src/build-assets.ts index 1f0af7047..e43041d21 100644 --- a/website/hero-animation/src/build-assets.ts +++ b/website/hero-animation/src/build-assets.ts @@ -1,6 +1,18 @@ import { mkdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import sharp, { type Sharp } from 'sharp' +const SITE_LOGO = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'site', + 'src', + 'assets', + 'logo.png', +) + const RAW = 'assets/icons-raw' const OUT = 'assets/gen' const GRAY = '#8a8a8a' @@ -66,6 +78,13 @@ async function logos() { } } +/** Official nostream mark (same file as the Astro site). */ +async function nostreamBrand() { + const fit = { fit: 'contain' as const, background: { r: 0, g: 0, b: 0, alpha: 0 } } + await sharp(SITE_LOGO).resize(512, 512, fit).png().toFile(`${OUT}/nostream.png`) + await sharp(SITE_LOGO).resize(512, 512, fit).grayscale().png().toFile(`${OUT}/nostream-gray.png`) +} + function rng(seed: number) { return () => { seed = (seed * 1664525 + 1013904223) % 4294967296 @@ -81,7 +100,7 @@ async function planet() { const rand = rng(7) const dots: string[] = [] - for (let i = 0; i < 5200; i++) { + for (let i = 0; i < 1800; i++) { const a = rand() * Math.PI * 2 const d = Math.sqrt(rand()) ** 0.55 * r const x = c + Math.cos(a) * d @@ -157,7 +176,7 @@ async function sparkles() { const c = S / 2 const rand = rng(11) const dots: string[] = [] - for (let i = 0; i < 1400; i++) { + for (let i = 0; i < 380; i++) { const a = rand() * Math.PI * 2 const d = 700 + rand() ** 1.6 * 280 const x = c + Math.cos(a) * d @@ -187,5 +206,5 @@ async function grain() { } await mkdir(OUT, { recursive: true }) -await Promise.all([logos(), planet(), glow(), grain(), grid(), sparkles()]) +await Promise.all([logos(), nostreamBrand(), planet(), glow(), grain(), grid(), sparkles()]) console.log(`assets written to ${OUT}/`) diff --git a/website/hero-animation/src/film.ts b/website/hero-animation/src/film.ts index fbb8b0a2e..9922ee4b1 100644 --- a/website/hero-animation/src/film.ts +++ b/website/hero-animation/src/film.ts @@ -1,13 +1,12 @@ -import { readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { Composition, Layer, LayerAnimation, Vignette } from 'framefields' +import { readSupportedNipCount } from './repo-meta.js' import { BG, BODY, DURATION, FG, - FG_2, FONT_MONO, FONT_SANS, FPS, @@ -26,9 +25,7 @@ import { const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const asset = (name: string) => path.join(ROOT, 'assets', 'gen', `${name}.png`) -const NIP_COUNT = String( - JSON.parse(readFileSync(path.join(ROOT, '..', '..', 'package.json'), 'utf8')).supportedNips.length, -) +const NIP_COUNT = String(readSupportedNipCount()) type Node = | ReturnType @@ -213,16 +210,17 @@ export async function buildFilm(): Promise { const planetSize = 1700 const haloSize = planetSize * (2000 / 1600) + const planetX = 1210 const planetY: Key[] = [ - [0, 1100], - [60, 520, 'power3.out'], - [HERO.end - 10, 505], - [WHAT.start + 25, 700, 'power2.inOut'], - [WHAT.end - 20, 710], - [FLOW.start + 20, 790, 'power2.inOut'], - [FEATURES.end - 20, 820], - [DEPLOY.start + 30, 500, 'power3.out'], - [LAST, 480], + [0, 1210], + [60, 640, 'power3.out'], + [HERO.end - 10, 625], + [WHAT.start + 25, 790, 'power2.inOut'], + [WHAT.end - 20, 800], + [FLOW.start + 20, 870, 'power2.inOut'], + [FEATURES.end - 20, 900], + [DEPLOY.start + 30, 580, 'power3.out'], + [LAST, 560], ] const planetOpacity: Key[] = [ [0, 0], @@ -237,7 +235,7 @@ export async function buildFilm(): Promise { add( Layer.image(asset('planet'), { position: 'absolute', - x: 1000, + x: planetX, y: 1100, width: planetSize, height: planetSize, @@ -252,7 +250,7 @@ export async function buildFilm(): Promise { add( Layer.image(asset('sparkles'), { position: 'absolute', - x: 1000 - haloOffset, + x: planetX - haloOffset, y: 1100 - haloOffset, width: haloSize, height: haloSize, @@ -269,28 +267,9 @@ export async function buildFilm(): Promise { // ── Persistent chrome: nav, footer ────────────────────────────────────── const always = { start: 0, inF: 12 } - add(rect(120, 45, 16, 16, { background: PINK }), show(45, always)) - add(mono('nostream', 146, 37, 22, { fill: FG, fontWeight: 700 }), show(37, always)) - - const navSize = 14 - const navSpacing = 2 - let navX = 1800 - for (const label of ['GITHUB', 'NIPS', 'DOCS']) { - navX -= monoWidth(label, navSize, navSpacing) - add(mono(label, navX, 42, navSize, { letterSpacing: navSpacing, fill: BODY }), show(42, always)) - if (label === 'GITHUB') { - add(img('github', navX - 28, 40, 20), show(40, { ...always, peak: 0.75 })) - navX -= 28 - } - navX -= 44 - } - - const footerRight = 'OPEN SOURCE · MIT LICENSE · GITHUB.COM/CAMERI/NOSTREAM' + add(img('nostream', 118, 41, 24), show(41, always)) + add(mono('nostream', 150, 37, 22, { fill: FG, fontWeight: 700 }), show(37, always)) add(mono('nostream', 120, 1022, 18, { fill: FG, fontWeight: 700 }), show(1022, always)) - add( - mono(footerRight, 1800 - monoWidth(footerRight, 13, 2), 1025, 13, { letterSpacing: 2, fill: BODY }), - show(1025, always), - ) const hx = 160 const sceneLabel = (text: string, y: number, start: number, end: number) => @@ -386,43 +365,31 @@ export async function buildFilm(): Promise { light('nostream', 24, heroOut) sceneLabel('NOSTREAM · OPEN-SOURCE NOSTR RELAY', 196, 6, heroOut) const headline: [string, string][] = [ - ['Your relay.', FG], - ['Your rules.', FG_2], - ['Nostr, wired in.', PINK], + ['A social network', FG], + ['nobody can', PINK], + ['switch off.', PINK], ] headline.forEach(([text, fill], i) => { - const y = 228 + i * 112 + const y = 216 + i * 96 add( - sans(text, hx - 6, y, 108, { fill, letterSpacing: -4, fontWeight: 800 }), + sans(text, hx - 6, y, 92, { fill, letterSpacing: -4, fontWeight: 800 }), show(y, { start: 12 + i * 9, end: heroOut, dy: 40, inF: 18 }), ) }) - const bodyY1 = 606 - const bodyY2 = 640 + const bodyY1 = 540 + const bodyY2 = 574 add( - mono('A production-ready Nostr relay, written in TypeScript.', hx, bodyY1, 21, { fill: BODY }), + mono('Own your posts, your followers and your reach.', hx, bodyY1, 21, { fill: BODY }), show(bodyY1, { start: 42, end: heroOut, dy: 14 }), ) - const prefix = 'Your server. Your data. No ' - const struck = 'middleman' - const strikeX = hx + monoWidth(prefix, 21) - add(mono(prefix, hx, bodyY2, 21, { fill: BODY }), show(bodyY2, { start: 48, end: heroOut, dy: 14 })) - add(mono(struck, strikeX, bodyY2, 21, { fill: MUTED }), show(bodyY2, { start: 48, end: heroOut, dy: 14 })) add( - mono('.', strikeX + monoWidth(struck, 21), bodyY2, 21, { fill: BODY }), + mono('A production-ready Nostr relay, written in TypeScript.', hx, bodyY2, 21, { fill: BODY }), show(bodyY2, { start: 48, end: heroOut, dy: 14 }), ) - add( - rect(strikeX - 2, bodyY2 + 14, 0, 2, { background: PINK }), - show(bodyY2 + 14, { start: 66, end: heroOut }).keys('width', [ - [66, 0], - [80, monoWidth(struck, 21) + 4, 'power2.out'], - ]), - ) - tabs(708, 62, heroOut) - const heroTerm = { y: 744, w: 780 } + tabs(628, 62, heroOut) + const heroTerm = { y: 664, w: 780 } add( rect(hx, heroTerm.y, heroTerm.w, 70, { background: PANEL, @@ -459,12 +426,11 @@ export async function buildFilm(): Promise { copyButton(hx + heroTerm.w - 18, heroTerm.y + 18, 74, heroOut) const stats: [string, string][] = [ - [NIP_COUNT, 'NIPS SUPPORTED'], - ['6', 'LIGHTNING PROCESSORS'], - ['MIT', 'OPEN SOURCE'], + ['Nostr + Marmot', 'PROTOCOL SUPPORT'], + ['MIT', 'LICENSE'], ] stats.forEach(([value, label], i) => { - const x = hx + i * 270 + const x = hx + i * 460 const s = 96 + i * 8 add(rect(x, 868, 1, 62, { background: LINE }), show(868, { start: s, end: heroOut })) add( @@ -504,7 +470,7 @@ export async function buildFilm(): Promise { num: '03', title: 'nostream', body: 'A production-ready relay\nin TypeScript that you\ndeploy and control.', - tag: 'self-hosted · open source', + tag: 'self-hosted · MIT license', }, ] const wc = { y: 430, w: 440, h: 330, gap: 140 } @@ -533,7 +499,7 @@ export async function buildFilm(): Promise { add(rect(cx - 6, cy - 6, 12, 12, { background: PINK, borderRadius: 6 }), at(iy)) } if (i === 2) { - add(rect(x + 44, iy + 12, 44, 44, { background: PINK, borderRadius: 2 }), at(iy)) + add(img('nostream', x + 44, iy + 12, 44), at(iy)) } light(i === 0 ? 'nostr' : 'nostream', s, s + 50) add(sans(title, x + 36, wc.y + 128, 38, { letterSpacing: -1, fill: accent ? PINK : FG }), at(wc.y + 128, 6)) @@ -570,34 +536,48 @@ export async function buildFilm(): Promise { ) const atF = (start: number, y: number) => show(y, { start, end: fEnd, dy: 16 }) - const core = { x: 760, y: 500, w: 400, h: 280 } + const checks = [ + 'verify id + schnorr signature', + 'rate limits · spam rules · PoW', + 'paid admission · NIP-42 auth', + 'NIP-11 relay information', + `${NIP_COUNT} NIPs supported`, + ] + const checkTop = 116 + const checkStep = 28 + const footerTop = checkTop + checks.length * checkStep + 14 + const coreH = footerTop + 38 + const core = { x: 760, y: 500, w: 400, h: coreH } const coreStart = f0 + 18 glow(core.x + core.w / 2, core.y + core.h / 2 - 40, 780, [[coreStart, fEnd]], 0.35) card(core.x, core.y, core.w, core.h, coreStart, fEnd, true) - add(rect(core.x + 30, core.y + 34, 18, 18, { background: PINK }), atF(coreStart + 4, core.y + 34)) + add(img('nostream', core.x + 28, core.y + 28, 24), atF(coreStart + 4, core.y + 28)) add(sans('nostream', core.x + 60, core.y + 20, 36, { letterSpacing: -1 }), atF(coreStart + 4, core.y + 20)) add( mono('RELAY CORE', core.x + 30, core.y + 72, 13, { letterSpacing: 3, fill: TEAL }), atF(coreStart + 6, core.y + 72), ) add(rect(core.x + 30, core.y + 100, core.w - 60, 1, { background: LINE }), atF(coreStart + 6, core.y + 100)) - const checks = [ - 'verify id + schnorr signature', - 'rate limits · spam rules · PoW', - 'paid admission · NIP-42 auth', - 'NIP-11 relay information', - ] checks.forEach((text, i) => { - const y = core.y + 116 + i * 30 + const y = core.y + checkTop + i * checkStep const tick = CYCLES[0] + 24 + i * 6 add(mono('·', core.x + 30, y, 16, { fill: MUTED }), show(y, { start: coreStart + 8, end: tick + 2, outF: 4 })) add(mono(text, core.x + 56, y, 16, { fill: MUTED }), show(y, { start: coreStart + 8, end: tick + 2, outF: 4 })) add(check(core.x + 28, y + 4, 13), show(0, { start: tick, end: fEnd, inF: 6 })) add(mono(text, core.x + 56, y, 16, { fill: FG }), show(y, { start: tick, end: fEnd, inF: 6 })) }) - add(img('typescript', core.x + 30, core.y + 242, 22), atF(coreStart + 10, core.y + 242)) - add(img('nodedotjs', core.x + 60, core.y + 242, 22), atF(coreStart + 10, core.y + 242)) - add(mono('TypeScript · Node.js', core.x + 94, core.y + 245, 14, { fill: MUTED }), atF(coreStart + 10, core.y + 245)) + add( + rect(core.x + 30, core.y + footerTop - 8, core.w - 60, 1, { background: LINE }), + atF(coreStart + 10, core.y + footerTop - 8), + ) + const stackIcon = 20 + const stackY = core.y + footerTop + 4 + add(img('typescript', core.x + 30, stackY, stackIcon), atF(coreStart + 10, stackY)) + add(img('nodedotjs', core.x + 30 + stackIcon + 8, stackY, stackIcon), atF(coreStart + 10, stackY)) + add( + mono('TypeScript · Node.js', core.x + 30 + (stackIcon + 8) * 2 + 6, stackY + 5, 14, { fill: MUTED }), + atF(coreStart + 10, stackY + 5), + ) // Clients const clients = [ @@ -816,7 +796,7 @@ export async function buildFilm(): Promise { ]), ) if (logos.length === 0) { - add(rect(x + 34, y + 38, 40, 40, { background: PINK, borderRadius: 2 }), at(y + 38)) + add(img('nostream', x + 34, y + 38, 40), at(y + 38)) } logos.forEach((name, j) => add(img(name, x + 28 + j * 34, y + 32 + j * 22, j ? 32 : 52), at(y + 32 + j * 22))) add(sans(title, x + 108, y + 36, 28, { letterSpacing: -0.6 }), at(y + 36, 6)) @@ -902,13 +882,8 @@ export async function buildFilm(): Promise { const windows = [...lit[name], allOn] const on = () => LayerAnimation.create().keys('opacity', windowKeys(windows, 1, 8, 10)) add(rect(58, y - 8, 28, 54, { background: BG }), show(y, always)) - if (name === 'nostream') { - add(rect(63, y + 3, 20, 20, { background: '#4a4a4a' }), show(y, always)) - add(rect(63, y + 3, 20, 20, { background: PINK }), on()) - } else { - add(img(`${name}-gray`, 59, y, 27), show(y, { ...always, peak: 0.7 })) - add(img(name, 59, y, 27), on()) - } + add(img(`${name}-gray`, 59, y, 27), show(y, { ...always, peak: 0.7 })) + add(img(name, 59, y, 27), on()) add(mono(String(i + 1).padStart(2, '0'), 64, y + 32, 11, { fill: MUTED }), show(y, always)) add(rect(46, y, 3, 27, { background: PINK }), on()) }) diff --git a/website/hero-animation/src/peering-film.ts b/website/hero-animation/src/peering-film.ts new file mode 100644 index 000000000..b109559f0 --- /dev/null +++ b/website/hero-animation/src/peering-film.ts @@ -0,0 +1,585 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { Composition, Layer, LayerAnimation, Vignette } from 'framefields' +import { + BG, + BODY, + FG, + FG_2, + FONT_MONO, + FONT_SANS, + FPS, + H, + LINE, + MUTED, + monoWidth, + PANEL, + PANEL_BORDER, + PINK, + TEAL, + W, + WIRE, +} from './theme.js' + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const asset = (name: string) => path.join(ROOT, 'assets', 'gen', `${name}.png`) + +/** + * Relay peering launch film. + * Same visual dialect as the 3.3.0 release film (omp.sh palette, dot grid, + * particle planet, terminal cards): 40 s @ 30 fps = + * 1200 frames. One job per scene, framed as what peering makes possible. + * + * Scene copy doc: docs/peering-film-copy.md (#942). On-screen claims follow the peering plan (#931–#940): + * - terms are flat or traffic-based fees for a period the operator sets, never per event + * - settlement is Lightning between relays, in sats + * - a cache window is operator-configured; "the last hour" is one such window + * No named operators, counts, or network statistics. + */ +export const DURATION_FILM = 1200 + +type Node = + | ReturnType + | ReturnType + | ReturnType + | ReturnType +type Key = [number, number] | [number, number, string] +type Window = [number, number] + +const LAST = DURATION_FILM - 1 + +/** Scene boundaries (frames). One idea each, then the inversion over the planet. */ +const PEOPLE = { start: 0, end: 240 } +const WORTH = { start: 240, end: 520 } +const FAST = { start: 520, end: 760 } +const MIDDLE = { start: 760, end: 980 } +const CLOSE = { start: 980, end: DURATION_FILM } + +const INK = '#08080a' + +const rect = (x: number, y: number, w: number, h: number, opts: Record = {}) => + Layer.box({ position: 'absolute', x, y, width: w, height: h, opacity: 0, ...opts }) + +const img = (name: string, x: number, y: number, size: number) => + Layer.image(asset(name), { position: 'absolute', x, y, width: size, height: size, opacity: 0 }) + + +function mergeWindows(windows: Window[], gap: number): Window[] { + const sorted = [...windows].sort((a, b) => a[0] - b[0]) + const out: Window[] = [] + for (const w of sorted) { + const prev = out[out.length - 1] + if (prev && w[0] <= prev[1] + gap) { + prev[1] = Math.max(prev[1], w[1]) + } else { + out.push([...w]) + } + } + return out +} + +function windowKeys(windows: Window[], peak = 1, inF = 14, outF = 14): Key[] { + const keys: Key[] = [] + let last = -1 + const push = (f: number, v: number, ease?: string) => { + const frame = Math.max(0, Math.min(LAST, Math.round(f))) + if (frame <= last) return + keys.push(ease ? [frame, v, ease] : [frame, v]) + last = frame + } + const merged = mergeWindows(windows, inF + outF) + if (merged[0][0] > 0) push(0, 0) + for (const [s, e] of merged) { + push(s, 0) + push(s + inF, peak, 'power2.out') + if (e < DURATION_FILM) { + push(e - outF, peak) + push(e, 0, 'power2.in') + } + } + return keys +} + +interface Show { + start: number + end?: number + dy?: number + inF?: number + outF?: number + peak?: number +} + +function show(y: number, { start, end = DURATION_FILM, dy = 0, inF = 14, outF = 14, peak = 1 }: Show) { + const anim = LayerAnimation.create().keys('opacity', windowKeys([[start, end]], peak, inF, outF)) + if (dy) { + const yk: Key[] = [ + [start, y + dy], + [start + inF + 8, y, 'power3.out'], + ] + if (end < DURATION_FILM) { + yk.push([end - outF, y]) + yk.push([end, y - dy * 0.6, 'power2.in']) + } + anim.keys('y', yk) + } + return anim +} + +const mono = (text: string, x: number, y: number, size: number, opts: Record = {}) => + Layer.text(text, { + position: 'absolute', + x, + y, + fontFamily: FONT_MONO, + fontSize: size, + fill: BODY, + opacity: 0, + ...opts, + }) + +const sans = (text: string, x: number, y: number, size: number, opts: Record = {}) => + Layer.text(text, { + position: 'absolute', + x, + y, + fontFamily: FONT_SANS, + fontSize: size, + fontWeight: 700, + fill: FG, + letterSpacing: -1.2, + opacity: 0, + ...opts, + }) + +/** Headline that rises in and fades with its scene, matching the 3.3.0 film. */ +function headlineAnim(y: number, start: number, end: number, delay: number) { + const a = start + delay + return LayerAnimation.create() + .keys('opacity', [ + [a, 0], + [a + 22, 1, 'power2.out'], + [end - 14, 1], + [end, 0, 'power2.in'], + ]) + .fromTo('y', y + 22, y, { start: a, end: a + 28, ease: 'power3.out' }) +} + +export async function buildFilm(): Promise { + const pending: { node: Node; anim?: LayerAnimation }[] = [] + const add = (node: Node, anim?: LayerAnimation) => pending.push({ node, anim }) + + const sceneKicker = (label: string, start: number, end: number, y = 132) => { + const hx = 168 + add(mono(label, hx, y, 15, { letterSpacing: 3, fill: TEAL }), show(y, { start: start + 4, end, dy: 10, inF: 12 })) + add( + rect(hx, y + 26, 72, 1, { background: TEAL }), + LayerAnimation.create() + .keys('opacity', windowKeys([[start + 8, end]], 0.9, 10, 10)) + .keys('width', [ + [start + 8, 0], + [start + 28, 72, 'power2.out'], + [Math.max(start + 30, end - 12), 72], + [end, 0], + ]), + ) + } + + // ── Backdrop: same stack as the landing hero (grid, planet, halo, grain) ─ + add( + Layer.image(asset('grid'), { position: 'absolute', x: 0, y: 0, width: W, height: H, opacity: 0 }), + LayerAnimation.create().keys('opacity', [ + [0, 0], + [28, 1, 'power2.out'], + [LAST, 1], + ]), + ) + + const planetSize = 1700 + const haloSize = planetSize * (2000 / 1600) + const planetX = 1180 + const planetY: Key[] = [ + [0, 1180], + [55, 680, 'power3.out'], + [WORTH.start + 20, 820, 'power2.inOut'], + [FAST.end - 20, 860], + [MIDDLE.start + 20, 920, 'power2.inOut'], + [CLOSE.start + 32, 560, 'power3.out'], + [LAST, 545], + ] + const planetOpacity: Key[] = [ + [0, 0], + [35, 0.82], + [WORTH.start + 20, 0.55], + [FAST.end - 20, 0.48], + [MIDDLE.end - 20, 0.42], + [CLOSE.start + 32, 1], + ] + add( + Layer.image(asset('planet'), { + position: 'absolute', + x: planetX, + y: 1180, + width: planetSize, + height: planetSize, + opacity: 0, + }), + LayerAnimation.create() + .keys('y', planetY) + .keys('opacity', planetOpacity) + .fromTo('rotation', 0, 48, { start: 0, end: LAST, ease: 'linear' }), + ) + const haloOffset = (haloSize - planetSize) / 2 + add( + Layer.image(asset('sparkles'), { + position: 'absolute', + x: planetX - haloOffset, + y: 1180 - haloOffset, + width: haloSize, + height: haloSize, + opacity: 0, + }), + LayerAnimation.create() + .keys('opacity', planetOpacity.map(([f, v, e]) => (e ? [f, (v as number) * 0.85, e] : [f, (v as number) * 0.85]) as Key)) + .keys( + 'y', + planetY.map(([f, v, e]) => (e ? [f, (v as number) - haloOffset, e] : [f, (v as number) - haloOffset]) as Key), + ) + .fromTo('rotation', 12, -38, { start: 0, end: LAST, ease: 'linear' }), + ) + + // ── Brand chrome: official mark + wordmark ─────────────────────────────── + add(img('nostream', 118, 38, 26), show(38, { start: 10, inF: 20 })) + add(mono('nostream', 152, 34, 22, { fill: FG, fontWeight: 700 }), show(34, { start: 10, inF: 20 })) + + const footer = 'OPEN SOURCE · MIT LICENSE · GITHUB.COM/CAMERI/NOSTREAM' + add(mono(footer, (W - monoWidth(footer, 17)) / 2, 1010, 17, { fill: MUTED }), show(1010, { start: CLOSE.start + 24, inF: 16 })) + + const hx = 168 + + // ── Scene 1 · PEOPLE: a relay for your people, not the whole network ─── + { + const end = PEOPLE.end + sceneKicker('01 — YOUR PEOPLE', PEOPLE.start, end) + add( + sans('Run a relay for your people.', hx, 196, 72), + headlineAnim(196, PEOPLE.start, end, 12), + ) + add( + sans('Not the whole network.', hx, 292, 44, { fill: PINK, fontWeight: 600, letterSpacing: -0.6 }), + headlineAnim(292, PEOPLE.start, end, 36), + ) + + const cards = [ + { k: 'SCOPE', t: 'Your people', d: 'the people you follow' }, + { k: 'PEERING', t: 'Their relays', d: 'the ones your people use' }, + { k: 'FETCHES', t: 'The rest', d: 'on demand' }, + ] + const cardY = 420 + const cardW = 520 + const cardH = 228 + cards.forEach((c, i) => { + const x = hx + i * (cardW + 36) + const s = PEOPLE.start + 58 + i * 16 + add( + Layer.box({ + position: 'absolute', + x, + y: cardY, + width: cardW, + height: cardH, + background: PANEL, + borderColor: i === 1 ? PINK : PANEL_BORDER, + borderWidth: i === 1 ? 2 : 1, + borderRadius: 10, + opacity: 0, + }), + show(cardY, { start: s, end, dy: 16 }), + ) + add( + Layer.box({ position: 'absolute', x: x + 36, y: cardY + 22, width: 36, height: 4, background: PINK, opacity: 0 }), + show(cardY + 22, { start: s + 6, end, inF: 8 }), + ) + add(mono(c.k, x + 36, cardY + 36, 18, { fill: TEAL }), show(cardY + 36, { start: s + 8, end, inF: 10 })) + add(sans(c.t, x + 36, cardY + 78, 40), show(cardY + 78, { start: s + 12, end, dy: 10, inF: 12 })) + add(mono(c.d, x + 36, cardY + 148, 22, { fill: FG_2 }), show(cardY + 148, { start: s + 20, end, inF: 12 })) + }) + add( + sans('Feel complete without ingesting everything.', hx, 708, 34, { fill: BODY, fontWeight: 500, letterSpacing: -0.4 }), + headlineAnim(708, PEOPLE.start, end, 118), + ) + } + + // ── Scene 2 · WORTH: flat or traffic-based fees, never per event ───────── + { + const end = WORTH.end + sceneKicker('02 — YOUR ARCHIVE', WORTH.start, end) + add(sans('Your archive', hx, 176, 84), headlineAnim(176, WORTH.start, end, 8)) + add( + sans('is worth something now.', hx, 274, 84, { fill: PINK }), + headlineAnim(274, WORTH.start, end, 26), + ) + add( + mono('Sell feeds to other relays — settled in sats.', hx, 378, 24, { fill: BODY }), + headlineAnim(378, WORTH.start, end, 44), + ) + + const panelX = hx + const panelY = 448 + const panelW = 1584 + const panelH = 340 + add( + Layer.box({ + position: 'absolute', + x: panelX, + y: panelY, + width: panelW, + height: panelH, + background: PANEL, + borderColor: PANEL_BORDER, + borderWidth: 1, + borderRadius: 10, + opacity: 0, + }), + show(panelY, { start: WORTH.start + 48, end, dy: 14 }), + ) + add(rect(panelX + 44, panelY + 18, 120, 3, { background: PINK }), show(panelY + 18, { start: WORTH.start + 58, end, inF: 10 })) + add(mono('PEERING TERMS', panelX + 44, panelY + 32, 18, { fill: TEAL }), show(panelY + 32, { start: WORTH.start + 64, end, inF: 10 })) + + const chipW = 248 + const chipH = 52 + const chipX = panelX + panelW - chipW - 28 + const chipY = panelY + 22 + add( + Layer.box({ position: 'absolute', x: chipX, y: chipY, width: chipW, height: chipH, background: PINK, borderRadius: 26, opacity: 0 }), + show(chipY, { start: WORTH.start + 150, end, inF: 10 }), + ) + add( + mono('PER PERIOD', chipX, chipY + 14, 22, { fill: INK, fontWeight: 700, align: 'center', width: chipW }), + show(chipY + 14, { start: WORTH.start + 156, end, inF: 10 }), + ) + + const rows = [ + { k: 'fee', v: 'flat or traffic-based — you set the terms', color: FG }, + { k: 'period', v: 'you set the length', color: FG_2 }, + { k: 'settlement', v: 'Lightning, in sats, between relays', color: FG, icon: true }, + { k: 'per event', v: 'never', color: PINK }, + ] + rows.forEach((row, i) => { + const yy = panelY + 96 + i * 56 + const s = WORTH.start + 78 + i * 14 + add(mono(row.k, panelX + 44, yy, 26, { fill: MUTED }), show(yy, { start: s, end, inF: 8 })) + const vx = panelX + 360 + if (row.icon) { + add( + Layer.image(asset('lightning'), { position: 'absolute', x: vx, y: yy - 4, width: 32, height: 32, opacity: 0 }), + show(yy - 4, { start: s + 6, end, inF: 8 }), + ) + } + add(mono(row.v, row.icon ? vx + 44 : vx, yy, 26, { fill: row.color }), show(yy, { start: s + 4, end, inF: 10 })) + }) + + add( + sans('A product, not a cost center.', hx, 828, 36, { fill: BODY, fontWeight: 500, letterSpacing: -0.4 }), + headlineAnim(828, WORTH.start, end, 168), + ) + } + + // ── Scene 3 · FAST: an operator-set cache window, illustrated as an hour ─ + { + const end = FAST.end + sceneKicker('03 — HOT FEED', FAST.start, end) + add(sans('Fast, everywhere.', hx, 196, 88), headlineAnim(196, FAST.start, end, 8)) + add( + sans('The hot feed, near the readers.', hx, 308, 42, { fill: BODY, fontWeight: 500, letterSpacing: -0.4 }), + headlineAnim(308, FAST.start, end, 30), + ) + + const trackX = hx + const trackY = 470 + const trackW = 1584 + const split = Math.round(trackW * 0.62) + add( + Layer.box({ position: 'absolute', x: trackX, y: trackY, width: trackW, height: 16, background: LINE, borderRadius: 8, opacity: 0 }), + show(trackY, { start: FAST.start + 52, end, inF: 10 }), + ) + add( + Layer.box({ position: 'absolute', x: trackX + split, y: trackY, width: trackW - split, height: 16, background: TEAL, borderRadius: 8, opacity: 0 }), + LayerAnimation.create() + .keys('opacity', [ + [FAST.start + 70, 0], + [FAST.start + 82, 1], + [end - 14, 1], + [end, 0, 'power2.in'], + ]) + .fromTo('width', 0, trackW - split, { start: FAST.start + 74, end: FAST.start + 130, ease: 'power2.out' }), + ) + add( + Layer.box({ position: 'absolute', x: trackX + split - 2, y: trackY - 14, width: 4, height: 44, background: PINK, borderRadius: 2, opacity: 0 }), + show(trackY - 14, { start: FAST.start + 88, end, inF: 8 }), + ) + + add(mono('OLDER THAN THE WINDOW', trackX, trackY - 48, 18, { fill: MUTED }), show(trackY - 48, { start: FAST.start + 60, end, inF: 10 })) + add(mono('THE HOT FEED', trackX + split + 16, trackY - 48, 18, { fill: TEAL }), show(trackY - 48, { start: FAST.start + 78, end, inF: 10 })) + + const notes = [ + { k: 'BEFORE THIS CUT', t: 'Ask an archive.', d: 'outside the window', edge: PANEL_BORDER }, + { k: 'CACHE PEERS', t: 'A rolling window', d: 'the last hour, or whatever you configure', edge: TEAL }, + ] + const noteY = 548 + const noteW = 760 + notes.forEach((n, i) => { + const x = trackX + i * (noteW + 64) + const s = FAST.start + 100 + i * 16 + add( + Layer.box({ + position: 'absolute', + x, + y: noteY, + width: noteW, + height: 176, + background: PANEL, + borderColor: n.edge, + borderWidth: n.edge === TEAL ? 2 : 1, + borderRadius: 10, + opacity: 0, + }), + show(noteY, { start: s, end, dy: 12 }), + ) + add(mono(n.k, x + 36, noteY + 28, 18, { fill: TEAL }), show(noteY + 28, { start: s + 8, end, inF: 8 })) + add(sans(n.t, x + 36, noteY + 64, 40), show(noteY + 64, { start: s + 12, end, dy: 8, inF: 12 })) + add(mono(n.d, x + 36, noteY + 122, 22, { fill: FG_2 }), show(noteY + 122, { start: s + 18, end, inF: 10 })) + }) + + add( + sans('Small relays feel instant — without owning the firehose.', hx, 800, 34, { fill: BODY, fontWeight: 500, letterSpacing: -0.3 }), + headlineAnim(800, FAST.start, end, 136), + ) + } + + // ── Scene 4 · MIDDLE: archives and caches behind user-facing edges ────── + { + const end = MIDDLE.end + sceneKicker('04 — THE MIDDLE', MIDDLE.start, end) + add(sans('Relays that only talk to relays.', hx, 188, 64), headlineAnim(188, MIDDLE.start, end, 8)) + add( + sans('The network can grow a middle.', hx, 274, 40, { fill: PINK, fontWeight: 600, letterSpacing: -0.5 }), + headlineAnim(274, MIDDLE.start, end, 28), + ) + + const roles = [ + { k: 'ARCHIVE', t: 'Deep history', d: 'backbone infrastructure', accent: false }, + { k: 'CACHE', t: 'Rolling window', d: 'hot feed near readers', accent: false }, + { k: 'EDGE', t: 'Readers connect', d: 'the relay readers connect to', accent: true }, + ] + const cardY = 430 + const cardW = 500 + const cardH = 240 + const gap = 42 + const centers = roles.map((_, i) => hx + i * (cardW + gap) + cardW / 2) + const lineY = cardY - 36 + add( + Layer.box({ + position: 'absolute', + x: centers[0], + y: lineY, + width: centers[2] - centers[0], + height: 2, + background: WIRE, + opacity: 0, + }), + show(lineY, { start: MIDDLE.start + 48, end, inF: 12 }), + ) + add( + Layer.box({ position: 'absolute', x: centers[0], y: lineY - 3, width: 72, height: 8, background: PINK, borderRadius: 4, opacity: 0 }), + LayerAnimation.create() + .keys('opacity', [ + [MIDDLE.start + 70, 0], + [MIDDLE.start + 82, 1], + [end - 14, 1], + [end, 0, 'power2.in'], + ]) + .fromTo('x', centers[0], centers[2] - 72, { start: MIDDLE.start + 78, end: MIDDLE.start + 150, ease: 'power1.inOut' }), + ) + + roles.forEach((c, i) => { + const x = hx + i * (cardW + gap) + const s = MIDDLE.start + 56 + i * 16 + add( + Layer.box({ + position: 'absolute', + x, + y: cardY, + width: cardW, + height: cardH, + background: PANEL, + borderColor: c.accent ? PINK : PANEL_BORDER, + borderWidth: c.accent ? 2 : 1, + borderRadius: 10, + opacity: 0, + }), + show(cardY, { start: s, end, dy: 16 }), + ) + add( + Layer.box({ position: 'absolute', x: centers[i] - 7, y: lineY - 6, width: 14, height: 14, background: c.accent ? PINK : TEAL, borderRadius: 7, opacity: 0 }), + show(lineY - 6, { start: s, end, inF: 8 }), + ) + const textW = cardW - 72 + add(mono(c.k, x + 36, cardY + 40, 18, { fill: TEAL }), show(cardY + 40, { start: s + 8, end, inF: 10 })) + add( + sans(c.t, x + 36, cardY + 84, 40, { width: textW, lineHeight: 1.12 }), + show(cardY + 84, { start: s + 12, end, dy: 10, inF: 12 }), + ) + add( + mono(c.d, x + 36, cardY + 156, 22, { fill: FG_2, width: textW, lineHeight: 1.35 }), + show(cardY + 156, { start: s + 20, end, inF: 12 }), + ) + }) + + add( + sans('Infrastructure behind. Users in front.', hx, 724, 34, { fill: BODY, fontWeight: 500, letterSpacing: -0.3 }), + headlineAnim(724, MIDDLE.start, end, 120), + ) + } + + // ── Scene 5 · CLOSE: the inversion, over the planet ───────────────────── + { + add( + mono('Today, every relay tries to be everything.', 0, 268, 26, { width: W, align: 'center', fill: MUTED }), + show(268, { start: CLOSE.start + 10, dy: 12, inF: 16 }), + ) + add( + sans('Each relay gets to be one thing well.', 0, 340, 72, { width: W, height: 96, align: 'center' }), + LayerAnimation.create() + .keys('opacity', [ + [CLOSE.start + 24, 0], + [CLOSE.start + 48, 1, 'power2.out'], + ]) + .fromTo('y', 362, 340, { start: CLOSE.start + 24, end: CLOSE.start + 52, ease: 'power3.out' }), + ) + add( + sans('The network assembles the rest.', 0, 448, 42, { width: W, height: 56, align: 'center', fill: PINK, fontWeight: 600, letterSpacing: -0.4 }), + show(448, { start: CLOSE.start + 58, dy: 16 }), + ) + const sign = 'nostream relay peering' + const mark = 44 + const signSize = 24 + const signW = monoWidth(sign, signSize) + const lockupW = mark + 14 + signW + const lockupX = (W - lockupW) / 2 + add(img('nostream', lockupX, 548, mark), show(548, { start: CLOSE.start + 88, inF: 18 })) + add(mono(sign, lockupX + mark + 14, 560, signSize, { fill: TEAL }), show(560, { start: CLOSE.start + 94, inF: 18 })) + } + + const film = new Composition({ + width: W, + height: H, + fps: FPS, + durationMs: (DURATION_FILM / FPS) * 1000, + backgroundColor: BG, + fonts: [path.join(ROOT, 'assets/fonts/JetBrainsMono.ttf'), path.join(ROOT, 'assets/fonts/Inter.ttf')], + }) + for (const { node, anim } of pending) film.add((anim ? node.animate(anim) : node) as never) + film.add(Layer.image(asset('grain'), { position: 'absolute', x: 0, y: 0, width: W, height: H, opacity: 0.85 }) as never) + film.apply(new Vignette({ strength: 0.16, radius: 0.95, softness: 0.7 })) + return film +} diff --git a/website/hero-animation/src/peering-render.ts b/website/hero-animation/src/peering-render.ts new file mode 100644 index 000000000..54a675eb3 --- /dev/null +++ b/website/hero-animation/src/peering-render.ts @@ -0,0 +1,35 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { HeadlessMediaRenderer } from 'framefields' +import { buildFilm } from './peering-film.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const OUTPUT = path.join(__dirname, '..', 'output', 'peering') + +const [mode = 'frames', ...args] = process.argv.slice(2) +const film = await buildFilm() +await fs.mkdir(path.join(OUTPUT, 'frames'), { recursive: true }) + +if (mode === 'frames') { + const renderer = new HeadlessMediaRenderer() + for (const frame of args.map(Number)) { + const file = path.join(OUTPUT, 'frames', `f${String(frame).padStart(4, '0')}.png`) + await fs.writeFile(file, await film.renderFrame({ frame, renderer })) + console.log(file) + } + process.exit(0) +} + +const quality = args[0] ?? 'high' +const started = Date.now() +const result = await film.renderVideo({ + outputPath: path.join( + OUTPUT, + quality === 'very_high' ? 'nostream-peering.mp4' : `nostream-peering-${quality}.mp4`, + ), + quality, +}) +await result.cleanup?.().catch(() => {}) +console.log(`${result.filePath} (${((Date.now() - started) / 1000).toFixed(1)} s)`) +process.exit(0) diff --git a/website/hero-animation/src/render.ts b/website/hero-animation/src/render.ts index e03bb8170..19d7df17d 100644 --- a/website/hero-animation/src/render.ts +++ b/website/hero-animation/src/render.ts @@ -3,6 +3,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { HeadlessMediaRenderer, startPreview } from 'framefields' import { buildFilm } from './film.js' +import { readSupportedNipCount } from './repo-meta.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const OUTPUT = path.join(__dirname, '..', 'output') @@ -41,5 +42,11 @@ const result = await film.renderVideo({ quality, }) await result.cleanup?.().catch(() => {}) -console.log(`${result.filePath} (${((Date.now() - started) / 1000).toFixed(1)} s)`) +const meta = { + nipCount: readSupportedNipCount(), + generatedAt: new Date().toISOString(), + output: path.basename(result.filePath), +} +await fs.writeFile(path.join(OUTPUT, 'film.meta.json'), `${JSON.stringify(meta, null, 2)}\n`) +console.log(`${result.filePath} (${((Date.now() - started) / 1000).toFixed(1)} s, ${meta.nipCount} NIPs)`) process.exit(0) diff --git a/website/hero-animation/src/repo-meta.ts b/website/hero-animation/src/repo-meta.ts new file mode 100644 index 000000000..c65d4839e --- /dev/null +++ b/website/hero-animation/src/repo-meta.ts @@ -0,0 +1,15 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..') + +/** Same source of truth as the website (`package.json` → `supportedNips`). */ +export const readSupportedNipCount = (): number => { + const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')) as { + supportedNips: number[] + } + return pkg.supportedNips.length +} + +export const REPO_PACKAGE_JSON = path.join(REPO_ROOT, 'package.json') diff --git a/website/site/.gitignore b/website/site/.gitignore new file mode 100644 index 000000000..ddce69b68 --- /dev/null +++ b/website/site/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.astro/ diff --git a/website/site/CHANGELOG.md b/website/site/CHANGELOG.md new file mode 100644 index 000000000..9a2a2c553 --- /dev/null +++ b/website/site/CHANGELOG.md @@ -0,0 +1,198 @@ +# nostream-website + +## 1.3.4 + +### Patch Changes + +- [#1000](https://github.com/cameri/nostream/pull/1000) [`dff00ba`](https://github.com/cameri/nostream/commit/dff00baca20dba191858abc1de50b69a743063e7) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Add canonical first-party production relay setup guide at `/docs/setup` and replace outdated third-party guides in `README.md`. + +## 1.3.3 + +### Patch Changes + +- [#965](https://github.com/cameri/nostream/pull/965) [`ee65257`](https://github.com/cameri/nostream/commit/ee652577236a2a2da6499f3a58f98e0ed3e581c4) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(ci): publish legal image tags and keep `latest` off prereleases + + A changesets relay release is tagged `nostream@3.7.0`, and `@` is not legal in a + Docker tag, so the `type=ref,event=tag` rule handed buildx a tag it refused: the + first live run of the Release workflow failed with `invalid tag ...: invalid +reference format` and published no image for the release ([#964](https://github.com/cameri/nostream/issues/964)). + + The tags are now computed by a shell step, so every tag is a value this + repository chose and can check: the bare version, `vX.Y.Z` as well beside it for + a legacy tag, and `latest` only when the release is not a prerelease. Each tag is + validated against Docker's grammar before the build, so an illegal one fails by + name at that step instead of at buildx. + + `docs/CI-MIGRATION.md`, `docs/DEPLOYMENT.md` and `docs/DEPLOY-RUNBOOK.md` no + longer promise `nostream@X.Y.Z` as a pinnable image tag, and the runbook now + names the receiver change that subscribing to `release` events depends on. + +- [#969](https://github.com/cameri/nostream/pull/969) [`8ea15fc`](https://github.com/cameri/nostream/commit/8ea15fce0f65916d6c9c1956eb2a3a77eb121a2b) Thanks [@Ferryx349](https://github.com/Ferryx349)! - feat(website): add the relay peering launch film and home-page section + +- [#967](https://github.com/cameri/nostream/pull/967) [`9c736c8`](https://github.com/cameri/nostream/commit/9c736c83d7ea116cd9492c619c7a2f7a156c20ba) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - ci(release): allow the image build to be dispatched for an existing tag + + A published release is immutable, so when the image build fails while publishing + it, the release cannot be re-published to retry: GitHub refuses the round trip + with `immutable cannot be changed when release is immutable`. That left [#964](https://github.com/cameri/nostream/issues/964)'s + release with no image and no way to build one. + + `release.yml` now accepts `workflow_dispatch` with a `tag` input, and refuses a + tag that is not a relay release, since the event carries no tag of its own. With + no release flags present the prerelease is read from the version, so a dispatch + of `nostream@3.8.0-rc.1` still leaves `latest` where it is. + +## 1.3.2 + +### Patch Changes + +- [#894](https://github.com/cameri/nostream/pull/894) [`02b70cf`](https://github.com/cameri/nostream/commit/02b70cf162365ac1c22e9c61f8cfecde2dd54f8a) Thanks [@Ferryx349](https://github.com/Ferryx349)! - Refresh the operator console marketing film with an updated notifications screenshot and re-ship `admin.mp4` / `admin-poster.webp` for the home page. + +- [#944](https://github.com/cameri/nostream/pull/944) [`82124b0`](https://github.com/cameri/nostream/commit/82124b02180b3c722e217ed39eb71c324ddec1a9) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - fix(ci): stop building and pushing container images outside a release + + Remove the container publish job from checks.yml so pushes to main no longer publish + images to GHCR. Container image builds and pushes are now restricted exclusively to + release.yml on published releases. Update deployment documentation and reference compose + stacks to target the released image tag (:latest) instead of :main. + +- [#916](https://github.com/cameri/nostream/pull/916) [`c40e570`](https://github.com/cameri/nostream/commit/c40e5704f6f63a86515de6bb8d4e243eb68fd830) Thanks [@Ferryx349](https://github.com/Ferryx349)! - Add Abhay Pandey Nostr profile link on the community page. + +## 1.3.1 + +### Patch Changes + +- [#899](https://github.com/cameri/nostream/pull/899) [`3ed70e4`](https://github.com/cameri/nostream/commit/3ed70e4233b210cdb382af55522933d0932d30b5) Thanks [@Ferryx349](https://github.com/Ferryx349)! - Prevent the home hero headline from overlapping the hero film on wide viewports by slightly reducing headline and film scale and reserving space above the frame. + +- [#896](https://github.com/cameri/nostream/pull/896) [`bb69376`](https://github.com/cameri/nostream/commit/bb69376a475a184013ade51dfca825c31019254f) Thanks [@Priyanshubhartistm](https://github.com/Priyanshubhartistm)! - Add the official Nostream Telegram channel and development chat to the community page, and a Telegram link to the footer. + +## 1.3.0 + +### Minor Changes + +- [#884](https://github.com/cameri/nostream/pull/884) [`e654852`](https://github.com/cameri/nostream/commit/e65485297bf4d9a20ccf8f78a4aae638a22655e1) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Add a social preview card generated from source. + + `website/site/scripts/build-social-card.mjs` renders one composition at two sizes: + `public/media/social-card.png` (1200×630, the site's `og:image`) and + `public/media/github-social-preview.png` (1280×640, the GitHub repository preview). + `Base.astro` now emits the full preview set — `og:image:width`, `og:image:height`, + `og:image:alt`, `twitter:image`, `twitter:title`, `twitter:description`, + `og:site_name`, `og:locale` — and takes an `ogImage` prop that defaults to the card; + `hero-poster.webp` stays the film's poster. + + The GitHub preview is uploaded by hand: **Settings → Social preview** in the + repository, which no workflow can set. + +### Patch Changes + +- [#882](https://github.com/cameri/nostream/pull/882) [`9146fd3`](https://github.com/cameri/nostream/commit/9146fd311cf88fe36818eae3d29c809c99c634fb) Thanks [@Ferryx349](https://github.com/Ferryx349)! - Replace the home page admin screenshot gallery with an operator console film (explain beats on the hero backdrop, full-frame UI shots) and a pause control; add Framefields sources and ship `admin.mp4` in site media. + +- [#889](https://github.com/cameri/nostream/pull/889) [`eb68475`](https://github.com/cameri/nostream/commit/eb68475eb324f319f23f64670d331faa3f684c83) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - The site now lives at **https://nostream.dev**, and every other host redirects to it over https. + + The router worker answers on `nostream.dev` and `www.nostream.dev` as well as the preview + domain, and `CANONICAL_HOST` decides which one serves content: any other host the routes + catch — `www`, or `ema.tnsor.network` for a non-preview path — gets a `301` to the canonical + host, and `http` gets a `301` to the same URL over `https`. One origin serving means one + canonical URL, so `www` and the apex cannot split search ranking by both answering `200`. + + Previews deliberately stay on `ema.tnsor.network/preview//`: that is why the domain + remains bound. A preview build must never sit on the production domain, and a preview's own + `SITE_URL` stays the preview origin so its absolute links and canonical tags cannot claim + `nostream.dev`. + + The domain is an input now, not a constant: `SITE_URL` for the build, `CANONICAL_HOST` for + the worker, both set in `deploy-website.yml`. Moving again is two lines rather than a search + through the source, the social card and the README. + + **Cloudflare-side setup this needs before it takes effect**: the `nostream.dev` zone must + exist, and both `nostream.dev` and `www.nostream.dev` resolve through the worker's + declared custom domains on the next deploy. Turning on the zone's _Always Use HTTPS_ + setting makes the upgrade happen at the edge, before the worker; the worker's own `301` + covers the case where that setting is off. + +- [#888](https://github.com/cameri/nostream/pull/888) [`07dc215`](https://github.com/cameri/nostream/commit/07dc2153d1d18e1641c8f0a079c542ab8187bcdc) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - A pull request's site preview now deploys even when the comment cannot be + written, and the CDN cache is purged after a deploy so a fresh preview is not + served the previous build. + + **A comment can no longer prevent a deploy.** The preview workflow's comment + steps ran before the deploy, so a pull request opened from a fork — whose + `GITHUB_TOKEN` is read-only whatever the `permissions:` block grants — failed at + `GraphQL: Resource not accessible by integration (addComment)` and skipped the + deploy entirely ([run 39134047965](https://github.com/cameri/nostream/actions/runs/39134047965)). + The deploy now runs first, every comment step is `continue-on-error`, and the + job grants `issues: write` (PR conversation comments are issue comments; + `pull-requests: write` alone does not cover them). + + **The cache is purged after deploying**, scoped to `https://ema.tnsor.network/preview//` + via the `prefixes` purge, so a stale window cannot be served — on 2026-10-18 + `/preview/885/` returned production HTML for about three minutes after the + build landed. The production workflow purges the whole zone after its router + deploy. Both purges skip with a notice when `CLOUDFLARE_ZONE_ID` is not set for + their environment, so an unconfigured environment deploys normally instead of + failing. + +- [#885](https://github.com/cameri/nostream/pull/885) [`f88a563`](https://github.com/cameri/nostream/commit/f88a5631f57b9f5a802b642688836271d4b67ec1) Thanks [@Priyanshubhartistm](https://github.com/Priyanshubhartistm)! - Add maintainer bio and social links on the community page for Priyanshu Bharti. + +## 1.2.5 + +### Patch Changes + +- [#858](https://github.com/cameri/nostream/pull/858) [`a0915b8`](https://github.com/cameri/nostream/commit/a0915b8c8b63d4a9b821d47b8bac9c88b1eb22b5) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - The nostream mark is now the site's pink (#ff2e88) instead of purple: logo asset recolored, favicon and apple-touch-icon regenerated, hero film's brand mark re-rendered. + +## 1.2.4 + +### Patch Changes + +- [#837](https://github.com/cameri/nostream/pull/837) [`ee7e06b`](https://github.com/cameri/nostream/commit/ee7e06b72284f07aacfd427128e99eefaab9cf84) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Website deploy workflow fix: the deploy queue is job-scoped so relay releases can't displace a pending website deploy (measured: the nostream@3.5.1 release run was cancelled with zero jobs at the workflow-level concurrency group, before its job-level `if` could skip it). The checkout stays on the triggering `nostream-website@` tag, which the `website` environment's branch policy admits — the deployed build is exactly the released commit. + +## 1.2.3 + +### Patch Changes + +- [#845](https://github.com/cameri/nostream/pull/845) [`2234a25`](https://github.com/cameri/nostream/commit/2234a25ad8e82401f7e48946317e29939954c52e) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Info-tip bubbles no longer clip at the viewport edge: the tooltip is centered on its (i) toggle, so near an edge it overflowed. On open (hover, tap or keyboard) the bubble now shifts horizontally to stay inside the viewport, and re-clamps on resize. + + Tooltip behavior is delegated at the document level, so every tip is covered even when its per-element listeners would have silently failed to attach. + + Site polish from an external review: the community participate cards have their own section heading, the hero headline is real DOM text with a pause control on the film, the console screenshots are labeled as real first-run console data, the honest-advertising copy matches what its warning shows, the nav's active-path comparison strips the deploy base, the get-started phase list explains the skipped numbers, and single-column grids no longer let nowrap content push the page wider than a phone viewport. The hero film is re-rendered at 1080p for the current 25-NIP list. + +## 1.2.2 + +### Patch Changes + +- [#844](https://github.com/cameri/nostream/pull/844) [`c352d72`](https://github.com/cameri/nostream/commit/c352d72249ad44176379a1c15c97115a68828d4e) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Preview cleanup no longer trips the deploy token's missing KV scope: wrangler delete runs with --force, skipping the dependents probe that errored after a successful delete. Preview workers are assets-only, so nothing else changes. + +- [#847](https://github.com/cameri/nostream/pull/847) [`1b1ef4f`](https://github.com/cameri/nostream/commit/1b1ef4f8bce745f170a5eb432944a85729ee9de9) Thanks [@Ferryx349](https://github.com/Ferryx349)! - Add maintainer bio, social links, and updated profile photo on the community page for Abhay Pandey. + +## 1.2.1 + +### Patch Changes + +- [#842](https://github.com/cameri/nostream/pull/842) [`72fca58`](https://github.com/cameri/nostream/commit/72fca5893b8b8f7daede8ad9841f124fe5278f53) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Add PR preview deployments: site changes are previewable at ema.tnsor.network/preview// and cleaned up on close. Requires the website-preview environment (CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID) to activate. + +- [#842](https://github.com/cameri/nostream/pull/842) [`72fca58`](https://github.com/cameri/nostream/commit/72fca5893b8b8f7daede8ad9841f124fe5278f53) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Website copy now leads with what each feature does for the user; the technical detail (NIP numbers, protocol names, command flags) moved into (i) info tooltips on hover, focus or tap. + + Also ships PR previews: every PR touching the site builds under `/preview//` and deploys its own worker (`nostream-web-pr-`); the production router worker (new) proxies `ema.tnsor.network/preview//*` to it, so the preview URL stays on the production domain. Previews are deleted when the PR closes. The production deploy switches from the assets-only worker to the router on the next release — same assets, same domain, plus the preview route. + +## 1.2.0 + +### Minor Changes + +- [#839](https://github.com/cameri/nostream/pull/839) [`91a5a7e`](https://github.com/cameri/nostream/commit/91a5a7edf9ebbb04ad749f775b5c3086192bfbc5) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Ship the hero film in two qualities: the page serves a high-res render by default and swaps to a low variant for data-saver clients and small screens (navigator.connection.saveData / effectiveType / viewport + DPR pick). build-media.mjs now emits both variants and records both sources in hero.meta.json. + +## 1.1.1 + +### Patch Changes + +- [#835](https://github.com/cameri/nostream/pull/835) [`4642dfd`](https://github.com/cameri/nostream/commit/4642dfd00cf3b8cd60ed4d29af847a1ff6aa3c06) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Soften the hero-film NIP-count guard to a warning while the landing film awaits its GPU re-render (NIP-77 grew the supported list to 25; the film still shows 24). + +## 1.1.0 + +### Minor Changes + +- [#828](https://github.com/cameri/nostream/pull/828) [`bb28deb`](https://github.com/cameri/nostream/commit/bb28debc5442202e3bb4e2d10ed03995d9640ded) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - feat(website): version and deploy the marketing site + + `website/site` joins the pnpm workspace as `nostream-website`, so the marketing site is versioned and released through the same changesets flow as the relay. The site builds as a static bundle (docs and changelog render from the repository at build time — no SSR) and deploys to Cloudflare Workers with static assets at ema.tnsor.network through CI. + + The deploy is a Cloudflare Worker with static assets (`website/site/wrangler.jsonc`), built and shipped by the new `Deploy Website` workflow after a release PR merges to `main`. + +### Patch Changes + +- [#832](https://github.com/cameri/nostream/pull/832) [`b2a997d`](https://github.com/cameri/nostream/commit/b2a997d608c031aac08237c7775f2e939d99bdd0) Thanks [@chappie-daemon](https://github.com/chappie-daemon)! - Bind ema.tnsor.network as a wrangler-declared custom domain so every deploy idempotently attaches it to the nostream-website worker. diff --git a/website/site/README.md b/website/site/README.md new file mode 100644 index 000000000..41ee7beac --- /dev/null +++ b/website/site/README.md @@ -0,0 +1,52 @@ +# nostream website (Astro) + +Landing page, docs and changelog for nostream, in the same omp.sh-style dialect as the Framefields films in [`../hero-animation`](../hero-animation). + +- `/` framed landing film ([PR #803](https://github.com/cameri/nostream/pull/803) / `film.ts`), sparkles, who it's for, feature pillars, relay peering film, admin & observability, NIPs +- `/get-started` copy-paste build command for the [`run-a-decentralized-social-network-relay`](https://schemaformat.ai/schematics/run-a-decentralized-social-network-relay/SCHEMATIC.md) agent schematic +- `/docs` deploy path chooser plus the repository markdown (README, CLI, CONFIGURATION, deploy docs, Redis, ...) rendered as pages +- `/changelog` rendered from `CHANGELOG.md` +- `/community` maintainer, channels and community guides + +Docs, the changelog, the version and the NIP list are read from the repository at build time, so the site never drifts from the code. + +## Develop + +Needs Node 24+. + +```bash +cd website/site +pnpm install +pnpm dev # http://localhost:4321 +pnpm build # → dist/ +pnpm check +``` + +## Film media + +Media comes from the landing film in `../hero-animation` (needs `ffmpeg` for `pnpm run media`): + +| Asset | Source | +|-------|--------| +| `hero.mp4`, `hero-low.mp4`, `hero-poster.webp`, `hero.meta.json` | Landing film — `src/film.ts` (`pnpm run render`, `pnpm run frames`) | +| `admin.mp4`, `admin-poster.webp` | Operator console film — `src/admin-film.ts` (`pnpm run render:admin`, `pnpm run frames:admin`) | +| `peering.mp4`, `peering-low.mp4`, `peering-poster.webp` | Relay peering launch film — `src/peering-film.ts` (`pnpm run render:peering`, `pnpm run frames:peering`); copy in `docs/peering-film-copy.md` | +| `planet.webp` | Particle planet — `src/build-assets.ts` (`pnpm run assets`) | + +```bash +cd website/hero-animation +pnpm run assets +pnpm run render && pnpm run frames +pnpm run render:admin && pnpm run frames:admin # when the admin film or screenshots change +pnpm run render:peering && pnpm run frames:peering # when the peering launch film changes +cd ../site +pnpm run media +``` + +## Brand mark + +Official logo: `src/assets/logo.png`. Used in nav/footer, favicon (`pnpm run` / build runs `scripts/build-brand.mjs`), terminal chrome, and the landing film (`website/hero-animation` reads the same file on `pnpm run assets`). + +## Admin console screenshots + +Still PNGs for the home-page console film live in `src/assets/screens/` (see the README there). The marketing section plays `public/media/admin.mp4`, not the gallery of static shots. diff --git a/website/site/astro.config.mjs b/website/site/astro.config.mjs new file mode 100644 index 000000000..36d2b48e4 --- /dev/null +++ b/website/site/astro.config.mjs @@ -0,0 +1,62 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'astro/config' + +const SITE_ROOT = path.dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = path.resolve(SITE_ROOT, '..', '..') +const REPO_BLOB = 'https://github.com/cameri/nostream/blob/main' + +// Keep in sync with DOCS in src/data/site.ts (config files cannot import TS). +const DOC_ROUTES = { + 'README.md': '/docs/quick-start', + 'CLI.md': '/docs/cli', + 'CONFIGURATION.md': '/docs/configuration', + 'deploy/README.md': '/docs/deploy', + 'docs/DEPLOY-RUNBOOK.md': '/docs/deploy-runbook', + 'docs/DEPLOYMENT.md': '/docs/container-publishing', + 'docs/REDIS.md': '/docs/redis', + 'CONTRIBUTING.md': '/docs/contributing', + 'SECURITY.md': '/docs/security', + 'CHANGELOG.md': '/changelog', +} + +/** Rewrite relative links in repo markdown to site routes, or to GitHub when the target is not published here. */ +function rehypeRepoLinks() { + return (tree, file) => { + const source = file.path ? path.relative(REPO_ROOT, file.path) : 'README.md' + const dir = path.posix.dirname(source.split(path.sep).join('/')) + + const visit = (node) => { + if (node.type === 'element' && node.tagName === 'a' && typeof node.properties?.href === 'string') { + const href = node.properties.href + if (!/^([a-z]+:|#|\/)/i.test(href)) { + const [target, hash] = href.split('#') + const resolved = path.posix.normalize(path.posix.join(dir, target)) + const route = DOC_ROUTES[resolved] + node.properties.href = route + ? `${route}${hash ? `#${hash}` : ''}` + : `${REPO_BLOB}/${resolved}${hash ? `#${hash}` : ''}` + } + if (/^https?:/i.test(node.properties.href)) { + node.properties.target = '_blank' + node.properties.rel = 'noopener noreferrer' + } + } + node.children?.forEach(visit) + } + visit(tree) + } +} + +export default defineConfig({ + site: process.env.SITE_URL ?? 'https://nostream.dev', + // PR previews build under /preview/; empty when unset (production). + base: process.env.SITE_BASE || undefined, + markdown: { + rehypePlugins: [rehypeRepoLinks], + shikiConfig: { theme: 'github-dark-default', wrap: false }, + }, + vite: { + server: { fs: { allow: [REPO_ROOT] } }, + }, +}) diff --git a/website/site/package.json b/website/site/package.json new file mode 100644 index 000000000..1f1c65118 --- /dev/null +++ b/website/site/package.json @@ -0,0 +1,33 @@ +{ + "name": "nostream-website", + "version": "1.3.4", + "private": true, + "type": "module", + "description": "nostream.dev landing page and docs, built with Astro", + "packageManager": "pnpm@10.33.0", + "scripts": { + "dev": "astro dev", + "predev": "node scripts/build-brand.mjs", + "prebuild": "node scripts/build-brand.mjs && node scripts/check-hero-nips.mjs", + "build": "astro build", + "preview": "astro preview", + "check": "astro check", + "media": "node scripts/build-media.mjs && node scripts/build-social-card.mjs", + "social": "node scripts/build-social-card.mjs" + }, + "engines": { + "node": ">=20.3" + }, + "dependencies": { + "@astrojs/markdown-remark": "^7.3.5", + "@fontsource-variable/inter": "^5.2.5", + "@fontsource/jetbrains-mono": "^5.2.5", + "astro": "^7.4.4", + "sharp": "^0.35.5" + }, + "devDependencies": { + "@astrojs/check": "^0.9.4", + "typescript": "^5.8.3", + "wrangler": "^4.151.0" + } +} diff --git a/website/site/public/apple-touch-icon.webp b/website/site/public/apple-touch-icon.webp new file mode 100644 index 000000000..e595a1bf3 Binary files /dev/null and b/website/site/public/apple-touch-icon.webp differ diff --git a/website/site/public/favicon.webp b/website/site/public/favicon.webp new file mode 100644 index 000000000..38a1c8103 Binary files /dev/null and b/website/site/public/favicon.webp differ diff --git a/website/site/public/media/admin-poster.webp b/website/site/public/media/admin-poster.webp new file mode 100644 index 000000000..311e76091 Binary files /dev/null and b/website/site/public/media/admin-poster.webp differ diff --git a/website/site/public/media/admin.mp4 b/website/site/public/media/admin.mp4 new file mode 100644 index 000000000..4463b8e5d Binary files /dev/null and b/website/site/public/media/admin.mp4 differ diff --git a/website/site/public/media/github-social-preview.png b/website/site/public/media/github-social-preview.png new file mode 100644 index 000000000..ca3d1d9ec Binary files /dev/null and b/website/site/public/media/github-social-preview.png differ diff --git a/website/site/public/media/hero-low.mp4 b/website/site/public/media/hero-low.mp4 new file mode 100644 index 000000000..2daed551c Binary files /dev/null and b/website/site/public/media/hero-low.mp4 differ diff --git a/website/site/public/media/hero-poster.webp b/website/site/public/media/hero-poster.webp new file mode 100644 index 000000000..cfdb1f35c Binary files /dev/null and b/website/site/public/media/hero-poster.webp differ diff --git a/website/site/public/media/hero.meta.json b/website/site/public/media/hero.meta.json new file mode 100644 index 000000000..58246752b --- /dev/null +++ b/website/site/public/media/hero.meta.json @@ -0,0 +1,6 @@ +{ + "nipCount": 25, + "generatedAt": "2026-10-17T00:09:46.344Z", + "sourceVideo": "output/nostream-hero.mp4", + "sourceLowVideo": "output/nostream-hero-low.mp4" +} diff --git a/website/site/public/media/hero.mp4 b/website/site/public/media/hero.mp4 new file mode 100644 index 000000000..9830c8a9c Binary files /dev/null and b/website/site/public/media/hero.mp4 differ diff --git a/website/site/public/media/peering-low.mp4 b/website/site/public/media/peering-low.mp4 new file mode 100644 index 000000000..0832c3fd9 Binary files /dev/null and b/website/site/public/media/peering-low.mp4 differ diff --git a/website/site/public/media/peering-poster.webp b/website/site/public/media/peering-poster.webp new file mode 100644 index 000000000..c58a7b875 Binary files /dev/null and b/website/site/public/media/peering-poster.webp differ diff --git a/website/site/public/media/peering.mp4 b/website/site/public/media/peering.mp4 new file mode 100644 index 000000000..5a98db037 Binary files /dev/null and b/website/site/public/media/peering.mp4 differ diff --git a/website/site/public/media/planet.webp b/website/site/public/media/planet.webp new file mode 100644 index 000000000..0e9ab9a1f Binary files /dev/null and b/website/site/public/media/planet.webp differ diff --git a/website/site/public/media/social-card.png b/website/site/public/media/social-card.png new file mode 100644 index 000000000..7d540733e Binary files /dev/null and b/website/site/public/media/social-card.png differ diff --git a/website/site/scripts/apply-base.mjs b/website/site/scripts/apply-base.mjs new file mode 100644 index 000000000..442b547cf --- /dev/null +++ b/website/site/scripts/apply-base.mjs @@ -0,0 +1,74 @@ +// Prefix root-absolute asset/link paths in built HTML with the site base. +// Astro's `base` rewrites astro-managed URLs; hand-written hrefs (nav links, +// icons, media) stay root-absolute, so this pass rewrites them for PR previews +// served under /preview//. +// Usage: SITE_BASE=/preview/42 node scripts/apply-base.mjs +import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs' +import { join } from 'node:path' + +const base = process.env.SITE_BASE +if (!base || base === '/') { + console.log('apply-base: SITE_BASE not set, nothing to do') + process.exit(0) +} +const norm = base.endsWith('/') ? base : `${base}/` + +const DIST = new URL('../dist/', import.meta.url).pathname + +const walk = (dir) => + readdirSync(dir).flatMap((name) => { + const p = join(dir, name) + if (statSync(p).isDirectory()) return walk(p) + return p + }) + +// HTML attributes that carry site-internal root paths. External (http(s):), +// protocol-relative (//), in-page (#) and already-based paths are skipped. +const ATTRS = ['href', 'src', 'poster', 'content'] +let changed = 0 + +for (const file of walk(DIST)) { + if (!/\.(html|css)$/.test(file)) continue + let html = readFileSync(file, 'utf8') + let out = html + if (file.endsWith('.html')) { + // Prefix each root-absolute path with the base; skip paths already based, + // protocol-relative URLs and pure-fragment links. + const prefix = norm.replace(/\/$/, '') // '/preview/99' + out = html + for (const attr of ATTRS) { + const re = new RegExp(`(${attr}=)"(/[^"]*)"`, 'gi') + out = out.replace(re, (m, a, path) => { + if (path.startsWith(prefix + '/') || path === prefix) return m + return `${a}"${prefix}${path}"` + }) + } + // srcset entries ("path 640w, path 1280w") and CSS url("/..."). + out = out.replace(/(srcset=")([^"]*)(")/gi, (m, a, list, z) => { + const parts = list.split(',').map((entry) => { + const [p, ...rest] = entry.trim().split(/\s+/) + if (!p || !p.startsWith('/') || p.startsWith(prefix + '/')) return entry.trim() + return [prefix + p, ...rest].join(' ') + }) + return `${a}${parts.join(', ')}${z}` + }) + out = out.replace(/url\("(\/[^"]*)"\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url("${prefix}${p}")`) + out = out.replace(/url\('(\/[^']*)'\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url('${prefix}${p}')`) + } else { + // CSS files: url(/...) only. + const prefix = norm.replace(/\/$/, '') + out = html.replace(/url\("(\/[^"]*)"\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url("${prefix}${p}")`) + out = out.replace(/url\('(\/[^']*)'\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url('${prefix}${p}')`) + out = out.replace(/url\((\/[^)]*)\)/gi, (m, p) => + p.startsWith(prefix + '/') ? m : `url(${prefix}${p})`) + } + if (out !== html) { + writeFileSync(file, out) + changed++ + } +} +console.log(`apply-base: rewrote ${changed} files under ${norm}`) diff --git a/website/site/scripts/build-brand.mjs b/website/site/scripts/build-brand.mjs new file mode 100644 index 000000000..867a7b507 --- /dev/null +++ b/website/site/scripts/build-brand.mjs @@ -0,0 +1,23 @@ +// Favicon and static icon sizes from src/assets/logo.png (official mark). +import { existsSync, mkdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const SRC = path.join(ROOT, 'src', 'assets', 'logo.png') +const PUBLIC = path.join(ROOT, 'public') + +if (!existsSync(SRC)) { + console.warn('build-brand: skip (missing src/assets/logo.png)') + process.exit(0) +} + +mkdirSync(PUBLIC, { recursive: true }) + +const fit = { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } + +await sharp(SRC).resize(32, 32, fit).webp({ quality: 90 }).toFile(path.join(PUBLIC, 'favicon.webp')) +await sharp(SRC).resize(180, 180, fit).webp({ quality: 92 }).toFile(path.join(PUBLIC, 'apple-touch-icon.webp')) + +console.log('build-brand: favicon.webp, apple-touch-icon.webp ← src/assets/logo.png') diff --git a/website/site/scripts/build-media.mjs b/website/site/scripts/build-media.mjs new file mode 100644 index 000000000..5916d8ddb --- /dev/null +++ b/website/site/scripts/build-media.mjs @@ -0,0 +1,170 @@ +// Builds web-sized media in public/ from the Framefields landing film in ../hero-animation. +// First: pnpm run assets && pnpm run render && pnpm run frames (src/film.ts → output/nostream-hero.mp4) +// Usage: pnpm run media (HERO_SRC=/path/to/nostream-hero.mp4 to override; must ship film.meta.json beside the mp4) +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const HERO_DIR = path.join(ROOT, '..', 'hero-animation') +const LANDING = path.join(HERO_DIR, 'output') +const MEDIA = path.join(ROOT, 'public', 'media') +const HERO_META = path.join(MEDIA, 'hero.meta.json') +const LANDING_POSTER = 'f0130.png' +const ADMIN_LANDING = path.join(LANDING, 'admin') +const ADMIN_POSTER = 'f0050.png' +const PEERING_LANDING = path.join(LANDING, 'peering') +const PEERING_POSTER = 'f1100.png' + +mkdirSync(MEDIA, { recursive: true }) + +/** film.meta.json must live next to the mp4 we encode — never reuse meta from another render. */ +const readFilmMeta = (videoPath) => { + const metaPath = path.join(path.dirname(videoPath), 'film.meta.json') + if (!existsSync(metaPath)) return null + return JSON.parse(readFileSync(metaPath, 'utf8')) +} + +const planet = path.join(HERO_DIR, 'assets', 'gen', 'planet.png') +if (existsSync(planet)) { + await sharp(planet).resize(1600).webp({ quality: 78 }).toFile(path.join(MEDIA, 'planet.webp')) + console.log('planet → public/media') +} else { + console.warn('skip planet: run `pnpm run assets` in website/hero-animation first') +} + +const landingPoster = path.join(LANDING, 'frames', LANDING_POSTER) +if (existsSync(landingPoster)) { + await sharp(landingPoster).resize(1920).webp({ quality: 90 }).toFile(path.join(MEDIA, 'hero-poster.webp')) + console.log('hero poster → public/media') +} else { + console.warn(`skip hero poster: run \`pnpm run frames\` in website/hero-animation (need frames/${LANDING_POSTER})`) +} + +const landingVideo = [ + process.env.HERO_SRC, + path.join(LANDING, 'nostream-hero.mp4'), + path.join(LANDING, 'nostream-hero-low.mp4'), +].find((p) => p && existsSync(p)) + +// The low variant ships beside the full one for mobile / Save-Data. It is the +// previous site video when a fresh high-res render replaces hero.mp4, or the +// low render when that is all hero-animation has produced. +const landingLowVideo = [ + process.env.HERO_LOW_SRC, + path.join(LANDING, 'nostream-hero-low.mp4'), + landingVideo === path.join(LANDING, 'nostream-hero-low.mp4') ? null : landingVideo, +].find((p) => p && existsSync(p) && p !== landingVideo) + +const encodeHero = (source, outPath, crf) => + execFileSync( + 'ffmpeg', + [ + '-y', '-loglevel', 'error', + '-i', source, + '-vf', 'unsharp=5:5:0.85:5:5:0.0', + '-an', '-c:v', 'libx264', '-preset', 'slow', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', + '-tune', 'animation', + '-crf', String(crf), + outPath, + ], + { stdio: 'inherit' }, + ) + +if (landingVideo) { + const filmMeta = readFilmMeta(landingVideo) + if (filmMeta?.nipCount == null) { + if (existsSync(HERO_META)) unlinkSync(HERO_META) + console.error( + `build-media: ${landingVideo} has no film.meta.json beside it — re-render in website/hero-animation (pnpm run render) or pick a source that includes meta.`, + ) + process.exit(1) + } + + encodeHero(landingVideo, path.join(MEDIA, 'hero.mp4'), 15) + console.log('hero.mp4 ← landing film (film.ts)') + + if (landingLowVideo) { + const lowMeta = readFilmMeta(landingLowVideo) + if (lowMeta?.nipCount === filmMeta.nipCount) { + encodeHero(landingLowVideo, path.join(MEDIA, 'hero-low.mp4'), 22) + console.log('hero-low.mp4 ← landing film (low variant)') + } else { + console.warn( + `skip hero-low.mp4: low source meta (${lowMeta?.nipCount}) disagrees with full source (${filmMeta.nipCount}) — regenerate both renders together`, + ) + } + } else { + console.warn('skip hero-low.mp4: no second source (run `pnpm run render:small` in website/hero-animation)') + } + + writeFileSync( + HERO_META, + `${JSON.stringify( + { + nipCount: filmMeta.nipCount, + generatedAt: filmMeta.generatedAt, + sourceVideo: path.relative(HERO_DIR, landingVideo), + ...(landingLowVideo ? { sourceLowVideo: path.relative(HERO_DIR, landingLowVideo) } : {}), + }, + null, + 2, + )}\n`, + ) + console.log(`hero.meta.json (${filmMeta.nipCount} NIPs, source ${path.basename(landingVideo)})`) +} else { + console.warn('skip hero.mp4: run `pnpm run render` in website/hero-animation first') +} + +const adminPoster = path.join(ADMIN_LANDING, 'frames', ADMIN_POSTER) +if (existsSync(adminPoster)) { + await sharp(adminPoster).resize(1920).webp({ quality: 90 }).toFile(path.join(MEDIA, 'admin-poster.webp')) + console.log('admin poster → public/media') +} else { + console.warn(`skip admin poster: run \`pnpm run frames:admin\` in website/hero-animation (need frames/${ADMIN_POSTER})`) +} + +const adminVideo = [process.env.ADMIN_SRC, path.join(ADMIN_LANDING, 'nostream-admin.mp4')].find((p) => p && existsSync(p)) + +if (adminVideo) { + encodeHero(adminVideo, path.join(MEDIA, 'admin.mp4'), 15) + console.log('admin.mp4 ← admin film (re-encoded for site / Workers asset limit)') +} else { + console.warn('skip admin.mp4: run `pnpm run render:admin` in website/hero-animation first') +} + +const peeringPoster = path.join(PEERING_LANDING, 'frames', PEERING_POSTER) +if (existsSync(peeringPoster)) { + await sharp(peeringPoster).resize(1920).webp({ quality: 90 }).toFile(path.join(MEDIA, 'peering-poster.webp')) + console.log('peering poster → public/media') +} else { + console.warn(`skip peering poster: run \`pnpm run frames:peering\` in website/hero-animation (need frames/${PEERING_POSTER})`) +} + +const peeringVideo = [ + process.env.PEERING_SRC, + path.join(PEERING_LANDING, 'nostream-peering.mp4'), + path.join(PEERING_LANDING, 'nostream-peering-very_high.mp4'), + path.join(PEERING_LANDING, 'nostream-peering-low.mp4'), +].find((p) => p && existsSync(p)) + +const peeringLowVideo = [ + process.env.PEERING_LOW_SRC, + path.join(PEERING_LANDING, 'nostream-peering-low.mp4'), + peeringVideo === path.join(PEERING_LANDING, 'nostream-peering-low.mp4') ? null : peeringVideo, +].find((p) => p && existsSync(p) && p !== peeringVideo) + +if (peeringVideo) { + encodeHero(peeringVideo, path.join(MEDIA, 'peering.mp4'), 15) + console.log('peering.mp4 ← peering launch film') + if (peeringLowVideo) { + encodeHero(peeringLowVideo, path.join(MEDIA, 'peering-low.mp4'), 22) + console.log('peering-low.mp4 ← peering launch film (low variant)') + } +} else { + console.warn('skip peering.mp4: run `pnpm run render:peering` in website/hero-animation first') +} + +console.log('done → public/media/') diff --git a/website/site/scripts/build-social-card.mjs b/website/site/scripts/build-social-card.mjs new file mode 100644 index 000000000..2647d72a8 --- /dev/null +++ b/website/site/scripts/build-social-card.mjs @@ -0,0 +1,193 @@ +// Builds the link-preview cards: one composition, two sizes, from the brand mark. +// +// public/media/social-card.png 1200×630 — the site's og:image +// public/media/github-social-preview.png 1280×640 — the repository social preview +// +// Both are the same drawing scaled: the wide card is the GitHub one at 1200/1280, so a +// change to the composition lands in both. Nothing is cropped or re-laid-out per size. +// +// Text is drawn as SVG rendered by sharp's librsvg over real woff2 font files, not by a +// system font lookup — a CI runner has no Inter installed, and a fallback face would +// silently change the card. The font files are the ones the site imports +// (@fontsource-variable/inter @fontsource/jetbrains-mono), so the card cannot drift from +// the page's typography. +// +// The backdrop is the page's own: the pink planet at public/media/planet.webp and the 28px +// dot grid from global.css. The home hero's spark stream is deliberately left out — a +// still frame cannot animate, so the particles land under the copy as noise instead of +// drift past it. +// +// Run with the site's other media: `pnpm run media`, or alone with `pnpm run social`. +import { existsSync, mkdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import sharp from 'sharp' + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const LOGO = path.join(ROOT, 'src', 'assets', 'logo.png') +const PLANET = path.join(ROOT, 'public', 'media', 'planet.webp') +const MEDIA = path.join(ROOT, 'public', 'media') +const SANS = path.join(ROOT, 'node_modules', '@fontsource-variable', 'inter', 'files', 'inter-latin-wght-normal.woff2') +const SANS_ITALIC = path.join(ROOT, 'node_modules', '@fontsource-variable', 'inter', 'files', 'inter-latin-wght-italic.woff2') +const MONO = path.join(ROOT, 'node_modules', '@fontsource', 'jetbrains-mono', 'files', 'jetbrains-mono-latin-400-normal.woff2') + +// Copied from src/styles/global.css; a card that disagrees with the page is the whole +// defect this file exists to fix. +const BG = '#08080a' +const FG = '#f7f7f7' +const FG2 = '#a8a8a8' +const PINK = '#ff2e88' +const MUTED = '#808080' +const LINE = '#26262a' +const SANS_STACK = 'Card' +const MONO_STACK = 'CardMono' + +const MARK_TOP = 96 + +const W = 1280 +const H = 640 + +// Layout: the mark sits on the left at the size a card preview actually renders it. +const MARK = 96 +const MARK_X = 84 +const WORD_X = MARK_X + MARK + 28 +const TEXT_X = 84 +const TEXT_RIGHT = W - 84 + +const escape = (text) => text.replace(/&/g, '&').replace(//g, '>') + +/** Font faces resolved from the packages, never from the system. */ +const fontFaces = () => { + for (const f of [SANS, MONO]) { + if (!existsSync(f)) throw new Error(`build-social-card: missing ${f} — run pnpm install in website/site`) + } + const italic = existsSync(SANS_ITALIC) ? `@font-face{font-family:'Card Sans';font-style:italic;src:url('file://${SANS_ITALIC}') format('woff2');font-weight:100 900;font-display:block}` : '' + return [ + ``, + ].join('') +} + +/** The 28px dot grid from global.css, faded toward the bottom (body::before). */ +const dots = (w, h) => { + const step = 28 + const cells = [] + for (let x = step / 2; x < w; x += step) { + for (let y = step / 2; y < h; y += step) { + // Ellipse 120% 80% at 50% 0% — near-opaque at the top, transparent by the bottom. + const dx = (x - w / 2) / (w * 0.6) + const dy = y / (h * 0.8) + const falloff = Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy)) + if (falloff <= 0.04) continue + cells.push(``) + } + } + return cells.join('') +} + +/** The planet, cropped to its disc and faded by a radial mask, as PlanetBackdrop does. */ +const planet = async (width) => { + if (!existsSync(PLANET)) throw new Error(`build-social-card: missing ${PLANET}`) + // The asset is 1600×1600 with the disc inset ~5% on each side; crop to the disc so the + // mask lands on the rim rather than in the padding. + const disc = Math.round(1600 * 0.88) + const offset = Math.round((1600 - disc) / 2) + const size = Math.round(width) + const base = await sharp(PLANET) + .extract({ left: offset, top: offset, width: disc, height: disc }) + .resize(size, size, { fit: 'fill' }) + .toBuffer() + const mask = Buffer.from( + ``, + ) + return sharp(base) + .ensureAlpha() + .composite([{ input: mask, blend: 'dest-in' }]) + .png() + .toBuffer() +} + +const composition = (w, h) => ` + + + ${dots(w, h)} + +` + +const text = (w, h) => ` + + ${fontFaces()} + + nostream + + A social network + nobody can switch off. + + Own your posts, your followers and your reach + + + Nostr & Marmot protocol support + MIT licensed + + nostream.dev + +` + +const mark = async (px) => { + if (!existsSync(LOGO)) throw new Error(`build-social-card: missing ${LOGO}`) + // The mark is cropped to its pink bar: as a favicon-scale drawing, its bar is the part + // that reads at card size and the glyph beside the wordmark would repeat the wordmark. + const meta = await sharp(LOGO).metadata() + return sharp(LOGO) + .resize(px, px, { fit: 'contain', background: { r: 8, g: 8, b: 10, alpha: 1 } }) + .png() + .toBuffer() + .then((buf) => ({ buf, meta })) +} + +const build = async (width, height, filename) => { + const base = await sharp(Buffer.from(composition(width, height))).png().toBuffer() + const { buf: logoBuf } = await mark(MARK) + const planetBuf = await planet(600) + const card = await sharp(base) + .composite([ + // Planet low and right, behind the empty half of the card: the copy runs down the + // left, so nothing pink is ever read over it. + { input: planetBuf, left: W - 470, top: H - 440 }, + { input: logoBuf, left: MARK_X, top: MARK_TOP }, + { input: Buffer.from(text(width, height)) }, + ]) + .png({ compressionLevel: 9 }) + .toBuffer() + // Each card is DRAWN at its own size rather than scaled: 1200x630 (1.905:1) for the + // og:image, 1280x640 (2:1) for GitHub. Resizing one into the other stretches the + // letterforms by ~4.7% vertically relative to their width, which shows in the headline. + const out = path.join(MEDIA, filename) + await sharp(card).png({ compressionLevel: 9 }).toFile(out) + const written = await sharp(out).metadata() + if (written.width !== width || written.height !== height) { + throw new Error(`build-social-card: ${filename} came out ${written.width}x${written.height}, expected ${width}x${height}`) + } + return out +} + +mkdirSync(MEDIA, { recursive: true }) + +// Both cards are drawn at their target size. The text layer keeps its 1280x640 design +// coordinates and scales uniformly into the smaller card, so nothing is stretched — only +// the canvas loses 10px of height and 80px of width. +const wide = await build(1200, 630, 'social-card.png') +const github = await build(1280, 640, 'github-social-preview.png') +console.log(`social card → ${path.relative(ROOT, wide)} (1200×630)`) +console.log(`github social preview → ${path.relative(ROOT, github)} (1280×640)`) diff --git a/website/site/scripts/check-hero-nips.mjs b/website/site/scripts/check-hero-nips.mjs new file mode 100644 index 000000000..39604b9bd --- /dev/null +++ b/website/site/scripts/check-hero-nips.mjs @@ -0,0 +1,41 @@ +// Ensures public/media/hero.meta.json matches package.json supportedNips.length. +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const REPO = path.join(ROOT, '..', '..') +const pkg = JSON.parse(readFileSync(path.join(REPO, 'package.json'), 'utf8')) +const expected = pkg.supportedNips.length +const metaPath = path.join(ROOT, 'public', 'media', 'hero.meta.json') +const heroPath = path.join(ROOT, 'public', 'media', 'hero.mp4') + +if (!existsSync(heroPath)) { + console.warn('check-hero-nips: skip (no public/media/hero.mp4 yet)') + process.exit(0) +} + +if (!existsSync(metaPath)) { + console.error( + `check-hero-nips: missing hero.meta.json — run \`pnpm run media\` in website/site after re-rendering the landing film.`, + ) + process.exit(1) +} + +const meta = JSON.parse(readFileSync(metaPath, 'utf8')) +if (meta.nipCount !== expected) { + // SOFTENED 2026-10-16 on Cameri's order: this guard hard-failed the site + // build (and with it, releases) because #830 added NIP-77 while the hero + // film still renders 24 — the re-render needs the GPU pipeline + // (cd website/hero-animation && pnpm run render, then cd ../site && pnpm + // run media) and no GPU is currently available. Restore `process.exit(1)` + // on this branch once the film is re-rendered. + console.warn( + `check-hero-nips: STALE FILM — hero film shows ${meta.nipCount} NIPs but package.json has ${expected}. Build continues; re-render before the next site copy refresh.`, + ) + console.warn('Re-render: cd website/hero-animation && pnpm run render') + console.warn('Then: cd ../site && pnpm run media') + process.exit(0) +} + +console.log(`check-hero-nips: ok (${expected} NIPs)`) diff --git a/website/site/src/assets/Abhay.jpg b/website/site/src/assets/Abhay.jpg new file mode 100644 index 000000000..1f8c37d02 Binary files /dev/null and b/website/site/src/assets/Abhay.jpg differ diff --git a/website/site/src/assets/cameri.jpg b/website/site/src/assets/cameri.jpg new file mode 100644 index 000000000..e04ccfd6e Binary files /dev/null and b/website/site/src/assets/cameri.jpg differ diff --git a/website/site/src/assets/logo.png b/website/site/src/assets/logo.png new file mode 100644 index 000000000..46cd1b24f Binary files /dev/null and b/website/site/src/assets/logo.png differ diff --git a/website/site/src/assets/priyanshu.jpg b/website/site/src/assets/priyanshu.jpg new file mode 100644 index 000000000..3ba5c0863 Binary files /dev/null and b/website/site/src/assets/priyanshu.jpg differ diff --git a/website/site/src/assets/screens/README.md b/website/site/src/assets/screens/README.md new file mode 100644 index 000000000..f97d1ffba --- /dev/null +++ b/website/site/src/assets/screens/README.md @@ -0,0 +1,17 @@ +# Admin console screenshots + +Source stills for the operator-console film (`website/hero-animation/src/admin-film.ts`). After replacing one, re-render the film and publish site media (Workers preview deploy requires every asset under 25 MiB — `pnpm run media` re-encodes the mp4 for `public/`). + +```bash +cd website/hero-animation +pnpm run render:admin && pnpm run frames:admin # → output/admin/nostream-admin.mp4 (+ frames/) +cd ../site +pnpm run media # → public/media/admin.mp4, admin-poster.webp +``` + +| File | Scene | +|------|-------| +| `metrices.png` | Live metrics | +| `nip66.png` | Network health (NIP-66) | +| `settings-editor.png` | Settings editor | +| `notification.png` | Notifications | diff --git a/website/site/src/assets/screens/metrices.png b/website/site/src/assets/screens/metrices.png new file mode 100644 index 000000000..cb9d70f1e Binary files /dev/null and b/website/site/src/assets/screens/metrices.png differ diff --git a/website/site/src/assets/screens/nip66.png b/website/site/src/assets/screens/nip66.png new file mode 100644 index 000000000..dc48254c9 Binary files /dev/null and b/website/site/src/assets/screens/nip66.png differ diff --git a/website/site/src/assets/screens/notification.png b/website/site/src/assets/screens/notification.png new file mode 100644 index 000000000..268361d7c Binary files /dev/null and b/website/site/src/assets/screens/notification.png differ diff --git a/website/site/src/assets/screens/settings-editor.png b/website/site/src/assets/screens/settings-editor.png new file mode 100644 index 000000000..2fa75675c Binary files /dev/null and b/website/site/src/assets/screens/settings-editor.png differ diff --git a/website/site/src/components/FilmFrame.astro b/website/site/src/components/FilmFrame.astro new file mode 100644 index 000000000..125cb10e6 --- /dev/null +++ b/website/site/src/components/FilmFrame.astro @@ -0,0 +1,40 @@ +--- +import FilmScreen from './FilmScreen.astro' + +interface Props { + src: string + lowSrc?: string + poster: string + title: string + class?: string +} +const { src, lowSrc, poster, title, class: className } = Astro.props +--- + +
+ +
+ + diff --git a/website/site/src/components/FilmScreen.astro b/website/site/src/components/FilmScreen.astro new file mode 100644 index 000000000..5874b00ea --- /dev/null +++ b/website/site/src/components/FilmScreen.astro @@ -0,0 +1,53 @@ +--- +import ScreenBar from './ScreenBar.astro' + +interface Props { + src: string + lowSrc?: string + poster: string + title: string + class?: string +} +const { src, lowSrc, poster, title, class: className } = Astro.props +--- + +
+ + +
+ + + + diff --git a/website/site/src/components/Footer.astro b/website/site/src/components/Footer.astro new file mode 100644 index 000000000..fe066e470 --- /dev/null +++ b/website/site/src/components/Footer.astro @@ -0,0 +1,144 @@ +--- +import Logo from './Logo.astro' +import { REPO, VERSION } from '../data/site' + +const columns = [ + { + title: 'Docs', + links: [ + { href: '/docs/quick-start', label: 'Quick start' }, + { href: '/docs/cli', label: 'CLI reference' }, + { href: '/docs/configuration', label: 'Configuration' }, + { href: '/docs/deploy', label: 'Deployment' }, + ], + }, + { + title: 'Project', + links: [ + { href: '/changelog', label: 'Changelog' }, + { href: '/community', label: 'Community' }, + { href: '/docs/contributing', label: 'Contributing' }, + { href: '/docs/security', label: 'Security' }, + { href: 'https://t.me/nostream', label: 'Telegram' }, + ], + }, + { + title: 'Source', + links: [ + { href: REPO, label: 'GitHub' }, + { href: `${REPO}/issues`, label: 'Issues' }, + { href: `${REPO}/releases`, label: 'Releases' }, + { href: `${REPO}/blob/main/LICENSE`, label: 'MIT license' }, + ], + }, +] +const external = (href: string) => href.startsWith('http') +--- + +
+
+
+ +

A production-ready Nostr relay written in TypeScript. Self-host it, charge for it, run it over Tor. Your relay, your rules.

+

● v{VERSION} · MIT

+
+ { + columns.map((col) => ( +
+

{col.title}

+ +
+ )) + } +
+
+ © {new Date().getFullYear()} nostream + MIT License +
+
+ + diff --git a/website/site/src/components/InfoTip.astro b/website/site/src/components/InfoTip.astro new file mode 100644 index 000000000..066bdd0d3 --- /dev/null +++ b/website/site/src/components/InfoTip.astro @@ -0,0 +1,180 @@ +--- +// Inline "(i)" affordance: the visible copy stays user-facing; the technical +// detail lives in a hover/focus/tap tooltip. Progressively enhanced — works +// without JS for hover and keyboard; the tap-open state uses the native +//
behavior so touch gets a reliable open/close with no scripts. +interface Props { + /** The technical detail shown inside the tooltip. */ + detail: string + /** Accessible name for the toggle, e.g. "rate limiting". */ + label?: string +} +const { detail, label = 'more detail' } = Astro.props +const id = `tip-${Astro.props['detail'] ? '' : ''}${Math.random().toString(36).slice(2, 10)}` +--- + +
+ + {detail} +
+ + + + diff --git a/website/site/src/components/Logo.astro b/website/site/src/components/Logo.astro new file mode 100644 index 000000000..2528b181c --- /dev/null +++ b/website/site/src/components/Logo.astro @@ -0,0 +1,44 @@ +--- +import { Image } from 'astro:assets' +import logoSrc from '../assets/logo.png' + +interface Props { + size?: number + /** Show the “nostream” wordmark beside the mark. */ + word?: boolean +} +const { size = 28, word = true } = Astro.props +--- + + + + diff --git a/website/site/src/components/Nav.astro b/website/site/src/components/Nav.astro new file mode 100644 index 000000000..8888e3b18 --- /dev/null +++ b/website/site/src/components/Nav.astro @@ -0,0 +1,242 @@ +--- +import Logo from './Logo.astro' +import { NAV, REPO, VERSION } from '../data/site' + +// Astro.url.pathname carries the configured base (preview deploys), so strip +// it before comparing against the base-relative NAV hrefs. +const base = import.meta.env.BASE_URL.replace(/\/$/, '') +const current = base && (Astro.url.pathname.replace(/\/$/, '') || '/').startsWith(base) + ? (Astro.url.pathname.replace(/\/$/, '') || '/').slice(base.length) || '/' + : Astro.url.pathname.replace(/\/$/, '') || '/' +const isActive = (href: string) => current === href || current.startsWith(`${href}/`) +--- + + + + + + diff --git a/website/site/src/components/PageHead.astro b/website/site/src/components/PageHead.astro new file mode 100644 index 000000000..c7ba226c4 --- /dev/null +++ b/website/site/src/components/PageHead.astro @@ -0,0 +1,25 @@ +--- +interface Props { + kicker: string + lead?: string +} +const { kicker, lead } = Astro.props +--- + +
+
+

{kicker}

+

+ {lead &&

{lead}

} +
+
+ + diff --git a/website/site/src/components/PlanetBackdrop.astro b/website/site/src/components/PlanetBackdrop.astro new file mode 100644 index 000000000..a1674c7a2 --- /dev/null +++ b/website/site/src/components/PlanetBackdrop.astro @@ -0,0 +1,66 @@ +--- +interface Props { + /** `cta` = bottom CTA band; `stage` = larger moon for framed film sections */ + variant?: 'cta' | 'stage' + /** More silver dots rising from the moon */ + density?: 'normal' | 'high' +} +const { variant = 'cta', density = 'normal' } = Astro.props +const maxPool = density === 'high' ? 420 : 260 +const spawnChance = density === 'high' ? 0.94 : 0.85 +--- + + + + diff --git a/website/site/src/components/ScreenBar.astro b/website/site/src/components/ScreenBar.astro new file mode 100644 index 000000000..becea642a --- /dev/null +++ b/website/site/src/components/ScreenBar.astro @@ -0,0 +1,25 @@ +--- +import Logo from './Logo.astro' + +interface Props { + title: string +} +const { title } = Astro.props +--- + +
+ + {title} + +
+ + diff --git a/website/site/src/components/docs/DeployPaths.astro b/website/site/src/components/docs/DeployPaths.astro new file mode 100644 index 000000000..37c9b9850 --- /dev/null +++ b/website/site/src/components/docs/DeployPaths.astro @@ -0,0 +1,301 @@ +--- +import ScreenBar from '../ScreenBar.astro' + +const paths = [ + { + id: 'compose', + label: 'Docker Compose', + who: 'Fastest way to a working relay on a laptop or a VPS.', + effort: '~5 min', + doc: { href: '/docs/quick-start#quick-start-docker-compose', label: 'Quick start (Docker Compose)' }, + commands: [ + '# clone and configure', + 'git clone https://github.com/cameri/nostream.git && cd nostream', + 'echo "SECRET=$(openssl rand -hex 128)" > .env', + '', + '# install the CLI from the checkout and start', + 'pnpm add -g .', + 'nostream start', + '', + '# with TLS via nginx + certbot', + 'RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com nostream start --nginx', + ], + }, + { + id: 'production', + label: 'Production (GHCR)', + who: 'A Linux server running the pre-built image. You keep only .env and optional overrides.', + effort: '~15 min', + doc: { href: '/docs/deploy', label: 'Production deployment' }, + commands: [ + '# copy release-managed files to the host', + 'chmod +x deploy/bootstrap.sh', + './deploy/bootstrap.sh /opt/nostream', + '', + '# edit /opt/nostream/.env, load ghcr.io/cameri/nostream:latest, then', + 'cd /opt/nostream', + 'docker compose up -d', + '', + '# later updates (run from your git clone — deploy/ is not copied to /opt/nostream)', + 'cd ~/nostream', + './deploy/recreate-relay.sh /opt/nostream', + ], + }, + { + id: 'haproxy', + label: 'Blue/green (HAProxy)', + who: 'Zero-downtime rollouts: two relays behind HAProxy, replaced one at a time.', + effort: '~30 min', + doc: { href: '/docs/deploy-runbook#haproxy-bluegreen-deploy', label: 'Deploy runbook & HAProxy' }, + commands: [ + '# after the new image is loaded', + 'cd /opt/nostream', + 'docker compose -f docker-compose.haproxy.yml run --rm nostream-migrate', + './rolling-relay-recreate.sh', + '', + '# the peer must stay up; wait for /readyz to return 200', + ], + }, + { + id: 'tor', + label: 'Tor & I2P', + who: 'Private or censorship-resistant relays with an .onion or .i2p address.', + effort: '~5 min', + doc: { href: '/docs/cli#common-workflows', label: 'CLI common workflows' }, + commands: [ + '# start with hidden services', + 'nostream start --tor --i2p', + '', + '# print the addresses to share', + 'nostream info --tor-hostname', + 'nostream info --i2p-hostname', + ], + }, + { + id: 'standalone', + label: 'Standalone (Node)', + who: 'Bring your own PostgreSQL and Redis, run Node directly. Good for development.', + effort: '~20 min', + doc: { href: '/docs/quick-start#quick-start-standalone', label: 'Quick start (standalone)' }, + commands: [ + '# set DB_* / REDIS_* and SECRET in .env, then', + 'corepack enable', + 'pnpm install', + 'pnpm db:migrate', + '', + 'mkdir .nostr', + 'cp resources/default-settings.yaml .nostr/settings.yaml', + 'pnpm start', + ], + }, +] + +const isComment = (line: string) => line.startsWith('#') +const kindOf = (line: string) => (line === '' ? 'blank' : isComment(line) ? 'c' : 'cmd') +--- + +
+
+ { + paths.map((p, i) => ( + + )) + } +
+ + { + paths.map((p, i) => ( + + )) + } +
+ + + + diff --git a/website/site/src/components/home/Audience.astro b/website/site/src/components/home/Audience.astro new file mode 100644 index 000000000..fa0154edc --- /dev/null +++ b/website/site/src/components/home/Audience.astro @@ -0,0 +1,144 @@ +--- +import InfoTip from '../InfoTip.astro' + +const audiences = [ + { + code: 'PUB', + title: 'Public relay operators', + body: 'Serve the open network without getting flattened by it.', + points: [ + { text: 'Handles floods of traffic without slowing everyone else down', tip: 'Sliding-window rate limits with backoff hints clients can respect' }, + { text: 'Blocks spam before it lands, without turning people away', tip: 'Adaptive proof-of-work, event size and created_at limits' }, + { text: 'Grows with your audience by adding more machines', tip: 'Horizontal workers with Redis fan-out' }, + ], + }, + { + code: 'PAID', + title: 'Paid relay operators', + body: 'Turn a relay into a service. Admission fees settle over Lightning.', + points: [ + { text: 'Get paid the way you already do', tip: 'ZEBEDEE, Nodeless, OpenNode, LNbits and LNURL processors' }, + { text: 'Invite-only groups and member checks, built in', tip: 'Admission checks and NIP-43 invite codes' }, + { text: 'Set your own prices and member lists', tip: 'Admission fee schedules with pubkey whitelists' }, + ], + }, + { + code: 'SELF', + title: 'Self-hosters', + body: 'A relay for you, your family or your community, on your own box.', + points: [ + { text: 'One command and you are running', tip: 'nostream start, with Tor, I2P or nginx + certbot' }, + { text: 'Your posts stay yours — copy them out any time', tip: 'Import and export events as JSON or JSONL' }, + { text: 'You decide who can post and who can read', tip: 'Restricted kinds, pubkey allow lists and NIP-42 auth' }, + ], + }, +] +--- + +
+
+
+
+

01 Who it's for

+

One relay. Three ways to run it.

+
+

+ Whether you are opening a relay to everyone, charging for access, or keeping it private, nostream ships the knobs + you need, without forking. +

+
+ +
+ { + audiences.map((a, i) => ( +
+
+ {a.code} + 0{i + 1} +
+

{a.title}

+

{a.body}

+
    + {a.points.map((p) => ( +
  • + {p.text} + +
  • + ))} +
+
+ )) + } +
+
+
+ + diff --git a/website/site/src/components/home/Console.astro b/website/site/src/components/home/Console.astro new file mode 100644 index 000000000..0dbdc3c1a --- /dev/null +++ b/website/site/src/components/home/Console.astro @@ -0,0 +1,198 @@ +--- +import FilmFrame from '../FilmFrame.astro' +import InfoTip from '../InfoTip.astro' + +const media = (file: string) => `${import.meta.env.BASE_URL}media/${file}`.replace(/\/\//, '/') + +const capabilities = [ + 'Login-gated so only you can reach it', + 'Plugs into the monitoring you already run', + 'Health endpoint for load balancers and zero-downtime deploys', + 'Self-hosted probes, labelled as one monitor among many', +] + +const features = [ + { + title: 'Live metrics', + body: 'Watch traffic and load as it happens, so you catch problems while they are small.', + tip: 'Events per second, rejections, connections, CPU and memory, streamed over SSE.', + }, + { + title: 'Network health', + body: 'Know your relay is reachable and fast from the outside, not just from your own machine.', + tip: 'NIP-66 probes for DNS, TLS, WebSocket read/write RTT and NIP-11, per target.', + }, + { + title: 'Settings editor', + body: 'Change settings confidently — mistakes are caught before they ship, and undo is one click.', + tip: 'Guided, validated edits with automatic backups and one-click restore.', + }, + { + title: 'Notifications', + body: 'Hear about trouble first, before your users notice.', + tip: 'Outbound alerts with a delivery log, so nothing gets lost silently.', + }, +] +--- + +
+
+
+
+

04 Admin & observability

+

Know what your relay is actually doing.

+
+
+

A built-in console for operators: no SSH, no SQL, no guessing at 3am.

+
    + {capabilities.map((c) =>
  • {c}
  • )} +
+
+
+ +
+

+ Silent looping video walkthrough of the /admin operator console: live metrics over SSE, the settings editor + with validated YAML and restore, NIP-66 network health probes, and alert notifications with a delivery log. + Use the pause control to stop playback. The sections below describe each beat in text. +

+ + +
+ +
    + { + features.map((feature, i) => ( +
  • +

    {feature.title}

    +

    + {feature.body} + {feature.tip && } +

    +
  • + )) + } +
+
+
+ + + + diff --git a/website/site/src/components/home/CtaBand.astro b/website/site/src/components/home/CtaBand.astro new file mode 100644 index 000000000..5786a7866 --- /dev/null +++ b/website/site/src/components/home/CtaBand.astro @@ -0,0 +1,76 @@ +--- +import PlanetBackdrop from '../PlanetBackdrop.astro' +import { REPO } from '../../data/site' +--- + +
+ +
+

Ship it

+

Run your own relay tonight.

+

Clone, set a secret, start. Docker Compose does the rest.

+
# clone and configure
+$ git clone https://github.com/cameri/nostream.git && cd nostream
+$ echo "SECRET=$(openssl rand -hex 128)" > .env
+$ pnpm add -g . && nostream start
+ +
+
+ + diff --git a/website/site/src/components/home/Hero.astro b/website/site/src/components/home/Hero.astro new file mode 100644 index 000000000..dbdaf0d13 --- /dev/null +++ b/website/site/src/components/home/Hero.astro @@ -0,0 +1,192 @@ +--- +import FilmFrame from '../FilmFrame.astro' +import { VERSION } from '../../data/site' +--- + +
+

+ Nostr runs on relays. + nostream is the one you own. +

+

+ A production-ready Nostr relay you deploy and control — self-hosted and open source. +

+ + +
+ + + + diff --git a/website/site/src/components/home/Nips.astro b/website/site/src/components/home/Nips.astro new file mode 100644 index 000000000..8d88e8b68 --- /dev/null +++ b/website/site/src/components/home/Nips.astro @@ -0,0 +1,105 @@ +--- +import { NIP_TITLES, SUPPORTED_MIPS, SUPPORTED_NIPS, nipUrl } from '../../data/site' + +const nips = SUPPORTED_NIPS.map((n) => ({ n, label: String(n).padStart(2, '0'), title: NIP_TITLES[n] ?? 'Nostr Implementation Possibility' })) +--- + +
+
+
+
+

05 NIPs supported

+

{SUPPORTED_NIPS.length} NIPs, advertised in NIP-11.

+
+

+ The same list your relay returns as supported_nips, read straight from the repository at build time. +

+
+ + + + { + SUPPORTED_MIPS.length > 0 && ( + + Marmot + + MIPs {SUPPORTED_MIPS.map((m) => String(m).padStart(2, '0')).join(', ')}: end-to-end encrypted group messaging + over Nostr, advertised as supported_mips. + + → + + ) + } +
+
+ + diff --git a/website/site/src/components/home/Peering.astro b/website/site/src/components/home/Peering.astro new file mode 100644 index 000000000..09bbb21c8 --- /dev/null +++ b/website/site/src/components/home/Peering.astro @@ -0,0 +1,92 @@ +--- +import FilmFrame from '../FilmFrame.astro' + +const media = (file: string) => `${import.meta.env.BASE_URL}media/${file}`.replace(/\/\//, '/') +--- + +
+
+
+
+

03 Relay peering

+

One job per relay. The network assembles the rest.

+
+
+

+ Peering lets operators specialize — community scope, deep archives, hot caches at the edge — without every + relay pretending to be the whole network. +

+
+
+ +
+ + +
+
+
+ + + + diff --git a/website/site/src/components/home/Pillars.astro b/website/site/src/components/home/Pillars.astro new file mode 100644 index 000000000..c136a4f93 --- /dev/null +++ b/website/site/src/components/home/Pillars.astro @@ -0,0 +1,186 @@ +--- +import InfoTip from '../InfoTip.astro' +import { SUPPORTED_MIPS, SUPPORTED_NIPS } from '../../data/site' + +const pillars = [ + { + code: 'NIP', + title: 'Speaks the whole protocol', + body: 'Works with the Nostr apps your people already use — chat, feeds, search and encrypted groups — with nothing to configure. ', + tip: `${SUPPORTED_NIPS.length} NIPs implemented relay-side, from basic flow to auth, counts, search and encrypted payloads, plus Marmot MIPs ${SUPPORTED_MIPS[0]}–${SUPPORTED_MIPS[SUPPORTED_MIPS.length - 1]} for E2EE group messaging.`, + wide: true, + }, + { + code: 'NET', + title: 'Reachable from anywhere', + body: 'Publish an .onion or .i2p address with a flag, so people on censored or privacy-first networks can still reach you.', + tip: 'nostream start --tor --i2p, then nostream info --tor-hostname.', + }, + { + code: 'OPS', + title: 'Stay on top of your relay', + body: 'See how your relay is doing at a glance, spot trouble before your users do, and change settings without fear of breaking things.', + tip: 'Live metrics, NIP-66 network health, NIP-11 mismatch warnings and a validated settings editor.', + }, + { + code: 'LN', + title: 'Charge for admission', + body: 'Turn publish access into a service. Invoices settle over Lightning with the provider you already run.', + tip: 'Invoices via ZEBEDEE, Nodeless, OpenNode, LNbits or any LNURL-verify provider.', + }, + { + code: 'LIM', + title: 'Keeps abusive traffic out', + body: 'Keeps spam and abusive traffic from slowing the relay for everyone else, so normal clients stay fast.', + tip: 'Rate limits, adaptive proof-of-work, kind and size rules. Clients get a rate-limited hint with a backoff.', + }, + { + code: 'I/O', + title: 'Your data, portable', + body: 'Take your events with you. Back up, migrate, or seed a new relay whenever you want.', + tip: 'Import and export events as JSON or JSONL in batches.', + full: true, + command: 'nostream export --output backup.jsonl --format jsonl', + }, +] +--- + +
+
+
+
+

02 Feature pillars

+

Everything a relay needs. Nothing it doesn't.

+
+

Protocol breadth, network reach and the ops tooling to keep it healthy, in one codebase you can read.

+
+ +
+ { + pillars.map((p, i) => ( +
+
+ {p.code} +

{p.title}

+

+ {p.body} + {p.tip && } +

+
+ {p.command && ( + + $ {p.command} + + )} + {p.code === 'NIP' && ( + + )} +
+ )) + } +
+
+
+ + diff --git a/website/site/src/content.config.ts b/website/site/src/content.config.ts new file mode 100644 index 000000000..063345577 --- /dev/null +++ b/website/site/src/content.config.ts @@ -0,0 +1,21 @@ +import { defineCollection } from 'astro:content' +import { glob } from 'astro/loaders' + +import { DOCS } from './data/site' + +const slugByFile = Object.fromEntries(DOCS.map((doc) => [doc.file, doc.slug])) + +/** Markdown read straight from the repository so the site never drifts from the docs. */ +const docs = defineCollection({ + loader: glob({ + base: '../..', + pattern: DOCS.map((doc) => doc.file), + generateId: ({ entry }) => slugByFile[entry] ?? entry, + }), +}) + +const changelog = defineCollection({ + loader: glob({ base: '../..', pattern: 'CHANGELOG.md', generateId: () => 'changelog' }), +}) + +export const collections = { docs, changelog } diff --git a/website/site/src/data/site.ts b/website/site/src/data/site.ts new file mode 100644 index 000000000..f8d8f624a --- /dev/null +++ b/website/site/src/data/site.ts @@ -0,0 +1,132 @@ +import pkg from '../../../../package.json' + +export const REPO = 'https://github.com/cameri/nostream' +/** Agent build spec for deploying nostream, added in cameri/schematics#99. */ +export const SCHEMATIC = { + name: 'run-a-decentralized-social-network-relay', + version: '0.1.0', + category: 'infrastructure', + source: './schematics/run-a-decentralized-social-network-relay', + description: + 'A production Nostr relay (nostream) on Docker — host prep, Postgres and Redis, GHCR image pinning, migrations-before-start, loopback HTTP, reverse-proxy sketches, NIP-11 and /readyz probes, optional settings and tailnet exposure.', + spec: 'https://schemaformat.ai/schematics/run-a-decentralized-social-network-relay/SCHEMATIC.md', + github: 'https://github.com/cameri/schematics/tree/main/schematics/run-a-decentralized-social-network-relay', + catalog: 'https://schemaformat.ai/#catalog', +} +export const VERSION: string = pkg.version +export const SUPPORTED_NIPS: number[] = pkg.supportedNips +export const SUPPORTED_MIPS: number[] = pkg.supportedMips ?? [] + +export const NAV = [ + { href: '/docs', label: 'Docs' }, + { href: '/community', label: 'Community' }, + { href: '/changelog', label: 'Changelog' }, +] + +/** Repository markdown rendered under /docs/. Paths are relative to the repo root. */ +export const DOCS = [ + { + slug: 'setup', + file: 'docs/SETUP.md', + title: 'Production setup guide', + group: 'Start here', + blurb: + 'Step-by-step production setup: host requirements, bootstrap, compose, reverse proxy with TLS, and verification.', + }, + { + slug: 'quick-start', + file: 'README.md', + title: 'Quick start', + group: 'Start here', + blurb: 'Requirements, Docker Compose and standalone setup, payments and maintenance.', + }, + { + slug: 'cli', + file: 'CLI.md', + title: 'CLI reference', + group: 'Start here', + blurb: 'nostream start, info, config, import/export, invites and the interactive TUI.', + }, + { + slug: 'configuration', + file: 'CONFIGURATION.md', + title: 'Configuration', + group: 'Operate', + blurb: 'Every environment variable and settings.yaml key, with defaults.', + }, + { + slug: 'deploy', + file: 'deploy/README.md', + title: 'Production deployment', + group: 'Deploy', + blurb: 'Minimal Compose stack on a pre-built GHCR image. Bootstrap, settings, services.', + }, + { + slug: 'deploy-runbook', + file: 'docs/DEPLOY-RUNBOOK.md', + title: 'Deploy runbook & HAProxy', + group: 'Deploy', + blurb: 'Single-relay and blue/green rollouts, verification, rollback.', + }, + { + slug: 'container-publishing', + file: 'docs/DEPLOYMENT.md', + title: 'Container publishing', + group: 'Deploy', + blurb: 'How images are built and published from CI.', + }, + { + slug: 'redis', + file: 'docs/REDIS.md', + title: 'Redis', + group: 'Operate', + blurb: 'Cache, rate limiting and broadcast fan-out. ACL setup and troubleshooting.', + }, + { + slug: 'contributing', + file: 'CONTRIBUTING.md', + title: 'Contributing', + group: 'Project', + blurb: 'Local development, tests and the changeset workflow.', + }, + { + slug: 'security', + file: 'SECURITY.md', + title: 'Security policy', + group: 'Project', + blurb: 'Supported versions and how to report a vulnerability.', + }, +] as const + +export const NIP_TITLES: Record = { + 1: 'Basic protocol flow', + 2: 'Follow list', + 3: 'OpenTimestamps attestations', + 4: 'Encrypted direct messages', + 5: 'DNS-based identifiers', + 9: 'Event deletion', + 11: 'Relay information document', + 12: 'Generic tag queries', + 13: 'Proof of work', + 14: 'Subject tag', + 15: 'End of stored events', + 16: 'Event treatment', + 17: 'Private direct messages', + 20: 'Command results', + 22: 'created_at limits', + 25: 'Reactions', + 28: 'Public chat', + 33: 'Addressable events', + 40: 'Expiration timestamp', + 42: 'Client authentication', + 43: 'Relay access & invites', + 44: 'Encrypted payloads', + 45: 'Event counts', + 50: 'Search', + 62: 'Request to vanish', + 65: 'Relay list metadata', + 66: 'Relay discovery & monitoring', +} + +export const nipUrl = (n: number) => + `https://github.com/nostr-protocol/nips/blob/master/${String(n).padStart(2, '0')}.md` diff --git a/website/site/src/layouts/Base.astro b/website/site/src/layouts/Base.astro new file mode 100644 index 000000000..914cc8995 --- /dev/null +++ b/website/site/src/layouts/Base.astro @@ -0,0 +1,129 @@ +--- +import '../styles/global.css' +import Footer from '../components/Footer.astro' +import Nav from '../components/Nav.astro' + +interface Props { + title?: string + description?: string + /** Path of the link-preview image. Defaults to the generated card. */ + ogImage?: string + /** Alt text for the preview image; the card is a picture of words, so it must be spoken. */ + ogImageAlt?: string +} + +const { + title, + description = 'nostream is a production-ready Nostr relay written in TypeScript: PostgreSQL + Redis, paid admission over Lightning, Tor & I2P, and a built-in admin console.', + // Built by scripts/build-social-card.mjs at 1200x630; the sizes below are that size and + // must change with it. hero-poster.webp stays the film's poster, not the preview. + ogImage = '/media/social-card.png', + ogImageAlt = 'nostream — a social network nobody can switch off. Own your posts, your followers and your reach.', +} = Astro.props +const fullTitle = title ? `${title} · nostream` : 'nostream: a social network nobody can switch off' +const canonical = new URL(Astro.url.pathname, Astro.site) +const ogImageUrl = new URL(ogImage, Astro.site) +// Only the generated card is known to be 1200x630. A caller passing a different image +// gets no size hints rather than a wrong crop. +const isCard = ogImage.endsWith('/social-card.png') +--- + + + + + + + {fullTitle} + + + + + + + + + + + + + + {isCard && } + {isCard && } + {isCard && } + + + + + + + + +