diff --git a/.env.example b/.env.example index a6740f7a7d..02e907b8cf 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,53 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# NIP-PL mobile push is an explicit deployment opt-in. A gateway URL alone +# never enables it. When enabled and the URL is absent, the canonical +# https://push.buzz.xyz/v1/deliveries/apns endpoint is used. +BUZZ_PUSH_ENABLED=false +# BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns + +# ----------------------------------------------------------------------------- +# Admin Dashboard (private moderation surface) +# ----------------------------------------------------------------------------- +# Host name that serves the moderation dashboard and its /api/admin/v1 +# endpoints. Leave unset to keep the admin surface absent. +# BUZZ_ADMIN_HOST=admin.localhost:3000 +# +# Authentication mode. Accepted values: nip98 (default), disabled. +# Any other value is a startup error. Token authentication was removed: +# BUZZ_ADMIN_TOKEN is ignored with a startup warning — remove it from the environment. +# BUZZ_ADMIN_AUTH=nip98 +# +# Option A — BUZZ_ADMIN_AUTH=nip98 (Nostr pubkey-based auth, default): +# NIP-98 HTTP Auth. Each request must carry an Authorization: Nostr header +# with a signed kind-27235 event. Authorized principals are resolved from: +# 1. RELAY_OPERATOR_PUBKEYS — comma-separated 64-char hex pubkeys (config Operators). +# 2. RELAY_OWNER_PUBKEY — implicit Operator fallback when RELAY_OPERATOR_PUBKEYS is unset. +# 3. relay_operators table — DB-managed Operator/Moderator roster. +# The dashboard requires a NIP-07 browser extension. +# Setting RELAY_OPERATOR_PUBKEYS for the admin console does NOT require +# RELAY_OPERATOR_API_ORIGIN; that origin is only for community provisioning +# (see below). When BUZZ_ADMIN_HOST is set, the relay advertises the admin +# origin in its NIP-11 document (`admin_api` field) so clients can auto-discover +# the console without manual URL entry. +# RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...] +# +# Option B — BUZZ_ADMIN_AUTH=disabled (network-layer auth only): +# Set only when the admin API is already protected at the network layer +# (VPN, private ingress). The relay logs a WARN on every startup. +# `just admin` defaults to this mode for local review. +# +# Directory holding the built dashboard assets (`pnpm -C admin-web build`). +# BUZZ_ADMIN_WEB_DIR=./admin-web/dist +# +# Canonical origin (http(s)://host[:port], no path) that community-provisioning +# NIP-98 requests are verified against. Required only to USE the provisioning +# endpoints (POST /operator/communities) — not for the admin console. When +# RELAY_OPERATOR_PUBKEYS is set but this is unset, the relay boots with a WARN +# and provisioning requests fail closed until it is set. +# RELAY_OPERATOR_API_ORIGIN=http://127.0.0.1:3000 + # Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and # authenticated desktop clients use this relay as the metadata/search proxy. # Keep the real value in your deployment's secret manager; never commit it. @@ -172,6 +219,12 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # Use `buzz-acp models` to discover available model IDs. # BUZZ_ACP_MODEL= +# Optional Databricks model-picker visibility filter. Discovery-only; this does +# not grant inference access. Comma-separated full-string * / ? patterns are +# OR-matched against raw workspace endpoint and Unity Catalog model-service IDs. +# Unset or blank shows every catalog entry. A nonblank value with no usable patterns is invalid. +# DATABRICKS_MODEL_FILTER=databricks-*,data_tools.goose.* + # ── Timeouts & sessions ────────────────────────────────────────────────────── # Max seconds per agent turn before timeout (default 320 = ~5 min). # BUZZ_ACP_TURN_TIMEOUT=320 diff --git a/.github/scripts/codex-security-review.js b/.github/scripts/codex-security-review.js index ac54adb665..3ac8f183e3 100644 --- a/.github/scripts/codex-security-review.js +++ b/.github/scripts/codex-security-review.js @@ -28,6 +28,16 @@ const completedMarker = (baseSha, headSha) => const reviewCommand = (headSha) => `${REVIEW_COMMAND} ${headSha}`; +const isOrganizationMember = (association) => + association === "MEMBER" || association === "OWNER"; + +const hasCurrentReviewLabel = (pullRequest) => + pullRequest.labels?.some( + (label) => + (typeof label === "string" ? label : label?.name) === + CURRENT_REVIEW_LABEL, + ) ?? false; + const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); @@ -352,6 +362,15 @@ async function prepare({ github, context, core }) { ); return; } + if ( + context.eventName === "pull_request_target" && + !isOrganizationMember(pullRequest.author_association) + ) { + core.info( + `Pull request #${prNumber} requires authorization from a Block organization member.`, + ); + return; + } if (pullRequest.head.sha !== requestedHeadSha) { core.setFailed( `Pull request #${prNumber} moved after this review was authorized. ` + @@ -362,6 +381,7 @@ async function prepare({ github, context, core }) { const baseSha = await getLiveMainSha({ github, context }); const commitRange = `${baseSha}...${pullRequest.head.sha}`; + core.setOutput("authorized", "true"); core.setOutput("pr_number", String(prNumber)); core.setOutput("trigger_actor", context.actor); core.setOutput("base_sha", baseSha); @@ -455,9 +475,12 @@ async function prepareBaseReconciliation({ github, context, core }) { } async function invalidatePullRequestUpdate({ github, context, core }) { - const association = context.payload.pull_request?.author_association || ""; - const existingOnly = association === "MEMBER" || association === "OWNER"; - await invalidate({ github, context, core, existingOnly }); + await invalidate({ + github, + context, + core, + existingOnlyForOrganizationMembers: true, + }); } async function invalidate({ @@ -466,6 +489,7 @@ async function invalidate({ core, prNumber: requestedPrNumber, existingOnly = false, + existingOnlyForOrganizationMembers = false, }) { const prNumber = Number( requestedPrNumber ?? context.payload.pull_request?.number, @@ -482,8 +506,14 @@ async function invalidate({ } const existing = await findReviewComment({ github, context, prNumber }); - if (!existing && existingOnly) { - await clearCurrentReview({ github, context, prNumber }); + const shouldOnlyUpdateExisting = + existingOnly || + (existingOnlyForOrganizationMembers && + isOrganizationMember(pullRequest.author_association)); + if (!existing && shouldOnlyUpdateExisting) { + if (existingOnly || hasCurrentReviewLabel(pullRequest)) { + await clearCurrentReview({ github, context, prNumber }); + } core.info(`PR #${prNumber} has no Codex security review to invalidate.`); return; } diff --git a/.github/scripts/codex-security-review.test.js b/.github/scripts/codex-security-review.test.js index 0e6d8068ea..8358cb3642 100644 --- a/.github/scripts/codex-security-review.test.js +++ b/.github/scripts/codex-security-review.test.js @@ -1,6 +1,8 @@ "use strict"; const assert = require("node:assert/strict"); +const { readFileSync } = require("node:fs"); +const path = require("node:path"); const test = require("node:test"); const { @@ -20,8 +22,14 @@ const NEW_BASE_SHA = "e".repeat(40); const MARKER = ""; const CURRENT_REVIEW_LABEL = "codex-security-review-current"; -function pullRequest({ baseSha = OLD_BASE_SHA, headSha = HEAD_SHA } = {}) { +function pullRequest({ + authorAssociation = "CONTRIBUTOR", + baseSha = OLD_BASE_SHA, + headSha = HEAD_SHA, + labels = [], +} = {}) { return { + author_association: authorAssociation, state: "open", base: { ref: "main", @@ -33,6 +41,7 @@ function pullRequest({ baseSha = OLD_BASE_SHA, headSha = HEAD_SHA } = {}) { repo: { full_name: "outside/buzz" }, }, changed_files: 1, + labels, }; } @@ -48,6 +57,7 @@ function harness({ const updated = []; const addedLabels = []; const removedLabels = []; + const removeLabelCalls = []; const outputs = new Map(); const failures = []; const notices = []; @@ -85,6 +95,7 @@ function harness({ addedLabels.push(input); }, removeLabel: async (input) => { + removeLabelCalls.push(input); if (!labelExists) { throw Object.assign(new Error("not found"), { status: 404 }); } @@ -139,6 +150,7 @@ function harness({ info, notices, outputs, + removeLabelCalls, removedLabels, storedComments, updated, @@ -198,6 +210,7 @@ test("prepare binds a member command to the named head SHA", async () => { await prepare(current); assert.deepEqual(current.failures, []); + assert.equal(current.outputs.get("authorized"), "true"); assert.equal(current.outputs.get("head_sha"), HEAD_SHA); assert.equal(current.outputs.get("base_sha"), BASE_SHA); assert.notEqual(current.outputs.get("base_sha"), OLD_BASE_SHA); @@ -218,6 +231,62 @@ test("prepare binds a member command to the named head SHA", async () => { ); }); +test("pull request authorization uses the live author association", async () => { + const member = harness({ + pull: pullRequest({ authorAssociation: "MEMBER" }), + }); + member.context.eventName = "pull_request_target"; + member.context.payload.pull_request = { + number: 6816, + head: { sha: HEAD_SHA }, + author_association: "CONTRIBUTOR", + }; + + await prepare(member); + + assert.equal(member.outputs.get("authorized"), "true"); + assert.deepEqual(member.failures, []); + + const external = harness({ + pull: pullRequest({ authorAssociation: "CONTRIBUTOR" }), + }); + external.context.eventName = "pull_request_target"; + external.context.payload.pull_request = { + number: 6816, + head: { sha: HEAD_SHA }, + author_association: "MEMBER", + }; + + await prepare(external); + + assert.equal(external.outputs.get("authorized"), undefined); + assert.deepEqual(external.failures, []); + assert.match(external.info.at(-1), /requires authorization/); +}); + +test("PR mutation jobs use pull request write permission", () => { + const workflow = readFileSync( + path.join(__dirname, "../workflows/codex-security-review.yml"), + "utf8", + ); + for (const jobName of [ + "reconcile-base-reviews", + "invalidate-previous-review", + "post-review", + ]) { + const start = workflow.indexOf(` ${jobName}:\n`); + assert.notEqual(start, -1, `missing workflow job ${jobName}`); + const remainder = workflow.slice(start + 2); + const nextJob = remainder.search(/^ [a-z][a-z0-9-]*:\n/m); + const job = + nextJob === -1 + ? workflow.slice(start) + : workflow.slice(start, start + 2 + nextJob); + assert.match(job, /^ pull-requests: write$/m); + assert.doesNotMatch(job, /^ issues: write$/m); + } +}); + test("prepare rejects review commands without a full exact SHA", async () => { const state = harness(); state.context.payload.comment.body = "@buzz-security-review"; @@ -431,7 +500,10 @@ test("base reconciliation does not create comments on unreviewed PRs", async () test("pull request updates invalidate member reviews without adding placeholders", async () => { const reviewed = harness({ - pull: pullRequest({ headSha: OTHER_HEAD_SHA }), + pull: pullRequest({ + authorAssociation: "MEMBER", + headSha: OTHER_HEAD_SHA, + }), comments: [ reviewComment( `${MARKER}\n\nold review`, @@ -441,7 +513,7 @@ test("pull request updates invalidate member reviews without adding placeholders reviewed.context.eventName = "pull_request_target"; reviewed.context.payload.pull_request = { number: 6816, - author_association: "MEMBER", + author_association: "CONTRIBUTOR", }; await reviewed.github.rest.issues.addLabels({ issue_number: 6816, @@ -456,23 +528,28 @@ test("pull request updates invalidate member reviews without adding placeholders ); assert.equal(reviewed.removedLabels.length, 1); - const unreviewed = harness(); + const unreviewed = harness({ + pull: pullRequest({ authorAssociation: "OWNER" }), + }); unreviewed.context.eventName = "pull_request_target"; unreviewed.context.payload.pull_request = { number: 6816, - author_association: "OWNER", + author_association: "CONTRIBUTOR", }; await invalidatePullRequestUpdate(unreviewed); assert.equal(unreviewed.created.length, 0); assert.equal(unreviewed.updated.length, 0); + assert.equal(unreviewed.removeLabelCalls.length, 0); - const external = harness(); + const external = harness({ + pull: pullRequest({ authorAssociation: "CONTRIBUTOR" }), + }); external.context.eventName = "pull_request_target"; external.context.payload.pull_request = { number: 6816, - author_association: "CONTRIBUTOR", + author_association: "MEMBER", }; await invalidatePullRequestUpdate(external); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7345febf0b..25a59c3243 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 with: key: sherpa-cache-v1 @@ -155,7 +155,7 @@ jobs: with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 with: workspaces: desktop/src-tauri @@ -347,7 +347,7 @@ jobs: target/ci/git-credential-nostr target/ci/backend-integration-tests.tar.zst key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }} - - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 + - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' @@ -516,7 +516,7 @@ jobs: run: | ./bin/pgschema apply --file schema/schema.sql --auto-approve docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql + psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ psql -U buzz -d buzz -qtA -c " INSERT INTO communities (id, host) @@ -677,7 +677,7 @@ jobs: run: | ./bin/pgschema apply --file schema/schema.sql --auto-approve docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ - psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql + psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/reconcile-schema-after-pgschema.sql docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ psql -U buzz -d buzz -qtA -c " INSERT INTO communities (id, host) @@ -780,6 +780,77 @@ jobs: --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Admin API nip98 read-write attribution test + # The only real HTTP → nip98 operator principal → mutation → cross-table + # attribution coverage: an authenticated operator's dismiss attributes + # to the operator's own key with relay_operator authority. Staffing + # PUT/DELETE attribution is covered by + # nip98_staffing_put_and_delete_write_attributed_audit_rows in the + # roster-audit lane below. #[ignore]d in the default suite — see + # api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(=api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Admin API unrostered-signer replay invariant + # The only causal proof that a validly-signing but unrostered key cannot + # consume NIP-98 replay slots: it asserts principal resolution fails + # BEFORE the replay ID is claimed (tracking.claim_count() == 0). This + # test is non-ignored, so it runs neither in Backend Integration's + # ignored-only selectors nor in the infra-free unit job — the unit job's + # api::admin selector excludes it because DB-free it only passes by + # waiting out the ~30s sqlx acquire timeout on a read-route fallthrough. + # It lives here so a reachable Postgres resolves (and fails) the lookup + # fast instead of timing out. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)' + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Admin API roster-audit / timeout / canonicalization security tests + # Security-review fixes for the roster admin API, all #[ignore]d in the + # default suite (they need Postgres) and selected by no other job: + # - buzz-db relay_operators::tests: audit pre-image trail, per-target + # lock serialization, insertion-time audit ordering, and + # audit-failure rollback coupling. + # - buzz-db relay_operators::tests last-operator invariant: sole DB + # operator cannot self-demote or self-delete to zero, config presence + # lifts the guard, and concurrent cross-target deletes racing to zero + # leave exactly one operator (roster-wide advisory lock). + # - buzz-relay api::admin: NIP-98 staffing writes attributed audit rows, + # adversarial expirationSecs rejected at the resolve route, mixed-case + # staffing normalizes to one canonical row. + # + # --test-threads=1: the last-operator invariant counts the roster + # globally, and the sole-operator tests clear the roster then assert + # their operator is the only one. They must not race each other on the + # shared test roster, so this lane runs serially. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + --test-threads=1 \ + -E '(package(buzz-db) and test(=relay_operators::tests::roster_mutations_write_pre_image_audit_rows)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_upserts_serialize_and_record_true_pre_image)) or (package(buzz-db) and test(=relay_operators::tests::audit_order_follows_seq_under_backward_clock)) or (package(buzz-db) and test(=relay_operators::tests::audit_insert_failure_rolls_back_roster_mutation)) or (package(buzz-db) and test(=relay_operators::tests::demoting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::deleting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::config_present_allows_deleting_last_db_operator)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_deletes_racing_to_zero_leave_one_operator)) or (package(buzz-relay) and test(=api::admin::tests::nip98_staffing_put_and_delete_write_attributed_audit_rows)) or (package(buzz-relay) and test(=api::admin::tests::resolve_route_rejects_adversarial_expiration_and_leaves_report_open)) or (package(buzz-relay) and test(=api::admin::tests::mixed_case_non_config_staffing_normalizes_to_one_row))' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Admin API escalation-scoping tests + # Escalation scoping for the moderation queue, all #[ignore]d (they need + # Postgres) and selected by no other job: + # - GET /reports defaults to the escalated-only backstop, scope=all + # restores full visibility, explicit status= overrides the default. + # - member reports with category 'illegal' auto-escalate at ingestion + # while every other category still lands 'open'. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E '(package(buzz-relay) and test(=api::admin::tests::reports_default_lists_escalated_only)) or (package(buzz-relay) and test(=api::admin::tests::reports_scope_all_lists_every_status)) or (package(buzz-relay) and test(=api::admin::tests::reports_explicit_status_filter_overrides_default)) or (package(buzz-db) and test(=moderation::tests::illegal_report_auto_escalates_at_ingest)) or (package(buzz-db) and test(=moderation::tests::non_illegal_report_lands_open_at_ingest)) or (package(buzz-db) and test(=relay_admin_actions::tests::auto_escalated_report_reopens_like_an_admin_escalated_one))' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -936,6 +1007,20 @@ jobs: - name: Build Android debug APK run: just mobile-build-android + mobile-swift: + name: Mobile Swift + runs-on: macos-latest + timeout-minutes: 10 + needs: [changes] + if: needs.changes.outputs.mobile == 'true' + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Build + run: swift build --package-path mobile/ios/BuzzPushKit + - name: Build release + run: swift build -c release --package-path mobile/ios/BuzzPushKit + - name: Test + run: swift test --package-path mobile/ios/BuzzPushKit security: name: Security runs-on: ubuntu-latest diff --git a/.github/workflows/codex-security-review.yml b/.github/workflows/codex-security-review.yml index ea6cd844bf..df558a877d 100644 --- a/.github/workflows/codex-security-review.yml +++ b/.github/workflows/codex-security-review.yml @@ -17,14 +17,14 @@ jobs: # This workflow posts an advisory review; its skipped jobs are not a merge # gate and must not be configured as required status checks. # MEMBER and OWNER are GitHub's associations for members of the `block` - # organization. Outside contributors require this exact command from one + # organization. The trusted prepare step checks the live PR instead of the + # event snapshot. Outside contributors require this exact command from one # of those members: @buzz-security-review if: >- github.repository == 'block/buzz' && ( ( github.event_name == 'pull_request_target' && - github.event.pull_request.draft == false && - contains(fromJSON('["MEMBER", "OWNER"]'), github.event.pull_request.author_association) + github.event.pull_request.draft == false ) || ( github.event_name == 'issue_comment' && github.event.issue.pull_request && @@ -38,6 +38,7 @@ jobs: contents: read pull-requests: read outputs: + authorized: ${{ steps.pr.outputs.authorized }} pr_number: ${{ steps.pr.outputs.pr_number }} trigger_actor: ${{ steps.pr.outputs.trigger_actor }} base_sha: ${{ steps.pr.outputs.base_sha }} @@ -111,8 +112,7 @@ jobs: cancel-in-progress: true permissions: contents: read - issues: write - pull-requests: read + pull-requests: write steps: - name: Checkout trusted workflow support if: matrix.pr_number != 0 @@ -192,8 +192,7 @@ jobs: cancel-in-progress: true permissions: contents: read - issues: write - pull-requests: read + pull-requests: write steps: - name: Checkout trusted workflow support uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -212,6 +211,7 @@ jobs: security-review: name: Run Codex Security Review needs: prepare-review + if: needs.prepare-review.outputs.authorized == 'true' runs-on: ubuntu-latest environment: codex-review timeout-minutes: 30 @@ -298,7 +298,7 @@ jobs: ACTIONS_ID_TOKEN_REQUEST_URL: '' with: openai-api-key: ${{ secrets.CODEX_REVIEW_API_KEY }} - codex-version: '0.149.0' + codex-version: '0.150.1' model: ${{ env.CODEX_MODEL }} codex-args: '["-c","model_reasoning_effort=${{ env.CODEX_REASONING_EFFORT }}"]' # The trusted authorization job already enforced repository membership. @@ -453,8 +453,7 @@ jobs: cancel-in-progress: true permissions: contents: read - issues: write - pull-requests: read + pull-requests: write env: CODEX_MODEL: gpt-5.6-sol REVIEW_JSON: ${{ needs.security-review.outputs.review_json }} diff --git a/.github/workflows/desktop-release-cache-proof.yml b/.github/workflows/desktop-release-cache-proof.yml index 71436d00c3..461e25a60f 100644 --- a/.github/workflows/desktop-release-cache-proof.yml +++ b/.github/workflows/desktop-release-cache-proof.yml @@ -67,7 +67,7 @@ jobs: name: Prove Linux cache visibility if: github.repository == 'block/buzz' runs-on: ubuntu-latest - container: ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea + container: ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 timeout-minutes: 15 defaults: run: diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index cdf8fe3bda..eb02b8f880 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -21,7 +21,7 @@ jobs: name: Build Linux canary if: github.repository == 'block/buzz' runs-on: ubuntu-latest - container: ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea + container: ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 timeout-minutes: 60 permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 187604fcf4..040817361a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -428,7 +428,7 @@ jobs: if: github.repository == 'block/buzz' runs-on: ubuntu-latest # Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh. - container: ubuntu:24.04@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea + container: ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 needs: setup timeout-minutes: 60 permissions: diff --git a/.intersect/sadscan.yaml b/.intersect/sadscan.yaml index a321714bcb..77710ae0d9 100644 --- a/.intersect/sadscan.yaml +++ b/.intersect/sadscan.yaml @@ -2,3 +2,14 @@ exclude_rules_for_files: sq.pii.cc.visa: - Cargo.lock + # Self-signed test fixture generated solely to exercise reqwest identity parsing. + kingfisher.privkey.2: + - "*apns-test-identity.pem" + - "*apns-test-key-only.pem" + - "*apns-test-encrypted-identity.pem" + - "*apns-test-mismatched-identity.pem" + np.pem.1: + - "*apns-test-identity.pem" + - "*apns-test-key-only.pem" + - "*apns-test-encrypted-identity.pem" + - "*apns-test-mismatched-identity.pem" diff --git a/AGENTS.md b/AGENTS.md index 6d97f4dfd9..24115501ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -469,6 +469,7 @@ description. See [PR #803](https://github.com/block/buzz/pull/803). 4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command. 5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly. 6. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing. +7. **`pgschema` omits seed DML and some storage parameters** — Fresh desired-state bootstraps use `./bin/pgschema apply`, which does not execute `INSERT` statements or preserve every table storage parameter from `schema/schema.sql`. Put each unsupported invariant in `scripts/reconcile-schema-after-pgschema.sql` as an idempotent convergence statement plus a live catalog or data assertion. Every `pgschema apply` caller must run that script. A string assertion against `schema.sql` alone does not prove the pgschema-created database has the intended state. --- diff --git a/Cargo.lock b/Cargo.lock index 3e99d18531..fe2b4e56ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,7 +265,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "x11rb", ] @@ -993,7 +993,7 @@ dependencies = [ "cc", "cfg-if 1.0.4", "constant_time_eq", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -1053,27 +1053,25 @@ checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc" [[package]] name = "bon" -version = "3.9.3" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +checksum = "9e3fac94a66da67200398458a25412bcc3f9b6443b5119a6cad9cf3ccfcd8cc6" dependencies = [ "bon-macros", - "rustversion", ] [[package]] name = "bon-macros" -version = "3.9.3" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +checksum = "d4654961ad0494e4774c5c60b4cb4cd0ae9b9d92d039d901638b1dba97ebebf5" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "ident_case", - "prettyplease", + "prettyplease 0.3.0", "proc-macro2", "quote", - "rustversion", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -1249,8 +1247,11 @@ dependencies = [ name = "buzz-auth" version = "0.1.0" dependencies = [ + "base64 0.22.1", "buzz-core", + "chrono", "hex", + "jsonwebtoken 10.4.0", "nostr 0.44.8", "rand 0.10.2", "serde", @@ -1570,7 +1571,6 @@ dependencies = [ "metrics-exporter-prometheus", "minicbor", "nostr 0.44.8", - "p256", "proptest", "rand 0.10.2", "reqwest 0.13.4", @@ -1950,12 +1950,12 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if 1.0.4", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -2098,14 +2098,14 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "futures-core", @@ -2337,12 +2337,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -2354,9 +2348,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -2506,22 +2500,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" -dependencies = [ - "cpubits", - "ctutils", - "getrandom 0.4.3", - "hybrid-array", - "num-traits", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -2539,7 +2517,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", "hybrid-array", "rand_core 0.10.1", ] @@ -2609,7 +2586,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", - "subtle", ] [[package]] @@ -2634,7 +2610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if 1.0.4", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto 0.3.0", @@ -2805,7 +2781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 3.0.4", + "syn 1.0.109", ] [[package]] @@ -2840,20 +2816,19 @@ dependencies = [ [[package]] name = "deadpool" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e" dependencies = [ "deadpool-runtime", - "num_cpus", "tokio", ] [[package]] name = "deadpool-redis" -version = "0.23.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bafa30c49dafe086d10116074e422ad7fc1c3cf554697e744a3ab112599ebd09" +checksum = "243852fff546d8c5aac6ceaac7587f7137915b102c3fbec6256a64ce52452bd2" dependencies = [ "deadpool", "redis", @@ -3060,7 +3035,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3133,21 +3108,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "ecdsa" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" -dependencies = [ - "der", - "digest 0.11.3", - "elliptic-curve", - "rfc6979", - "signature 3.0.0", - "spki", - "zeroize", -] - [[package]] name = "ed25519" version = "3.0.0" @@ -3184,27 +3144,6 @@ dependencies = [ "serde", ] -[[package]] -name = "elliptic-curve" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" -dependencies = [ - "base16ct", - "crypto-bigint", - "crypto-common 0.2.2", - "digest 0.11.3", - "ff", - "group", - "hybrid-array", - "pem-rfc7468", - "pkcs8", - "rand_core 0.10.1", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "email_address" version = "0.2.9" @@ -3292,7 +3231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3437,16 +3376,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "ff" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" -dependencies = [ - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3506,12 +3435,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -3783,8 +3713,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -3949,8 +3879,8 @@ dependencies = [ [[package]] name = "goose" -version = "1.47.0" -source = "git+https://github.com/aaif-goose/goose?rev=92caef37f8c80e0ee31ab9acad117662bc169f78#92caef37f8c80e0ee31ab9acad117662bc169f78" +version = "1.48.0" +source = "git+https://github.com/aaif-goose/goose?rev=fb15d4eade4ab3947b85e20852a18de269c789c9#fb15d4eade4ab3947b85e20852a18de269c789c9" dependencies = [ "agent-client-protocol", "agent-client-protocol-http", @@ -3983,10 +3913,10 @@ dependencies = [ "ignore", "image 0.24.9", "include_dir", - "indexmap 2.14.0", + "indexmap 2.14.1", "indoc", "jsonschema", - "jsonwebtoken", + "jsonwebtoken 11.0.0", "libc", "lru", "minijinja", @@ -4052,8 +3982,8 @@ dependencies = [ [[package]] name = "goose-acp-macros" -version = "1.47.0" -source = "git+https://github.com/aaif-goose/goose?rev=92caef37f8c80e0ee31ab9acad117662bc169f78#92caef37f8c80e0ee31ab9acad117662bc169f78" +version = "1.48.0" +source = "git+https://github.com/aaif-goose/goose?rev=fb15d4eade4ab3947b85e20852a18de269c789c9#fb15d4eade4ab3947b85e20852a18de269c789c9" dependencies = [ "quote", "syn 3.0.4", @@ -4062,22 +3992,25 @@ dependencies = [ [[package]] name = "goose-agent" version = "0.1.0-alpha.6" -source = "git+https://github.com/aaif-goose/goose?rev=92caef37f8c80e0ee31ab9acad117662bc169f78#92caef37f8c80e0ee31ab9acad117662bc169f78" +source = "git+https://github.com/aaif-goose/goose?rev=fb15d4eade4ab3947b85e20852a18de269c789c9#fb15d4eade4ab3947b85e20852a18de269c789c9" dependencies = [ "anyhow", "async-trait", + "futures", "goose-provider-types", "rmcp 3.1.4", "serde_json", "tokio", "tokio-util", + "tracing", + "tracing-futures", "uuid", ] [[package]] name = "goose-context-management" version = "0.1.0-alpha.6" -source = "git+https://github.com/aaif-goose/goose?rev=92caef37f8c80e0ee31ab9acad117662bc169f78#92caef37f8c80e0ee31ab9acad117662bc169f78" +source = "git+https://github.com/aaif-goose/goose?rev=fb15d4eade4ab3947b85e20852a18de269c789c9#fb15d4eade4ab3947b85e20852a18de269c789c9" dependencies = [ "anyhow", "async-trait", @@ -4093,7 +4026,7 @@ dependencies = [ [[package]] name = "goose-download-manager" version = "0.1.0-alpha.6" -source = "git+https://github.com/aaif-goose/goose?rev=92caef37f8c80e0ee31ab9acad117662bc169f78#92caef37f8c80e0ee31ab9acad117662bc169f78" +source = "git+https://github.com/aaif-goose/goose?rev=fb15d4eade4ab3947b85e20852a18de269c789c9#fb15d4eade4ab3947b85e20852a18de269c789c9" dependencies = [ "anyhow", "once_cell", @@ -4106,7 +4039,7 @@ dependencies = [ [[package]] name = "goose-provider-types" version = "0.1.0-alpha.6" -source = "git+https://github.com/aaif-goose/goose?rev=92caef37f8c80e0ee31ab9acad117662bc169f78#92caef37f8c80e0ee31ab9acad117662bc169f78" +source = "git+https://github.com/aaif-goose/goose?rev=fb15d4eade4ab3947b85e20852a18de269c789c9#fb15d4eade4ab3947b85e20852a18de269c789c9" dependencies = [ "anyhow", "async-stream", @@ -4132,7 +4065,7 @@ dependencies = [ [[package]] name = "goose-providers" version = "0.1.0-alpha.6" -source = "git+https://github.com/aaif-goose/goose?rev=92caef37f8c80e0ee31ab9acad117662bc169f78#92caef37f8c80e0ee31ab9acad117662bc169f78" +source = "git+https://github.com/aaif-goose/goose?rev=fb15d4eade4ab3947b85e20852a18de269c789c9#fb15d4eade4ab3947b85e20852a18de269c789c9" dependencies = [ "anyhow", "async-stream", @@ -4156,7 +4089,7 @@ dependencies = [ [[package]] name = "goose-sdk-types" version = "0.1.0-alpha.6" -source = "git+https://github.com/aaif-goose/goose?rev=92caef37f8c80e0ee31ab9acad117662bc169f78#92caef37f8c80e0ee31ab9acad117662bc169f78" +source = "git+https://github.com/aaif-goose/goose?rev=fb15d4eade4ab3947b85e20852a18de269c789c9#fb15d4eade4ab3947b85e20852a18de269c789c9" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", @@ -4165,22 +4098,11 @@ dependencies = [ "serde_json", ] -[[package]] -name = "group" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" -dependencies = [ - "ff", - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "h2" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -4188,7 +4110,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.5.0", - "indexmap 2.14.0", + "indexmap 2.14.1", "slab", "tokio", "tokio-util", @@ -4546,16 +4468,14 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ - "subtle", "typenum", - "zeroize", ] [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -4657,7 +4577,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -5019,9 +4939,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -5106,9 +5026,9 @@ dependencies = [ [[package]] name = "iroh" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72" +checksum = "329552fad4e46b5ccb4439d5635216f6289eec2c46f3fde4bc732d762694b9e5" dependencies = [ "backon", "blake3", @@ -5157,9 +5077,9 @@ dependencies = [ [[package]] name = "iroh-base" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" +checksum = "3ccf4cde68a02ef03c0095ab2c031c6adfe508f3bc8e7c258558a027ebe64a8e" dependencies = [ "curve25519-dalek 5.0.0", "data-encoding", @@ -5176,9 +5096,9 @@ dependencies = [ [[package]] name = "iroh-dns" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349" +checksum = "9a41224eb0140a5c519935d4073fc6b236d174080be401f9bf7da126b2ee3aa4" dependencies = [ "arc-swap", "cfg_aliases", @@ -5227,9 +5147,9 @@ dependencies = [ [[package]] name = "iroh-relay" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193" +checksum = "1b5f6f07e2c9b0c4bc82e2864be09f4aea4320165d5316e668616dbc822f428a" dependencies = [ "blake3", "bytes", @@ -5520,6 +5440,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem 3.0.6", + "serde", + "serde_json", + "signature 2.2.0", + "simple_asn1", + "zeroize", +] + [[package]] name = "jsonwebtoken" version = "11.0.0" @@ -5716,9 +5654,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" dependencies = [ "libc", ] @@ -5848,9 +5786,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.2" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" dependencies = [ "hashbrown 0.17.1", ] @@ -6499,7 +6437,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "indexmap 2.14.0", + "indexmap 2.14.1", "ipnet", "metrics", "metrics-util", @@ -6520,7 +6458,7 @@ dependencies = [ "crossbeam-epoch", "crossbeam-utils", "hashbrown 0.16.1", - "indexmap 2.14.0", + "indexmap 2.14.1", "metrics", "ordered-float 5.5.0", "quanta", @@ -6616,6 +6554,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -6892,9 +6840,9 @@ dependencies = [ "libc", "mac-addr", "ndk-context", - "netlink-packet-core", - "netlink-packet-route", - "netlink-sys", + "netlink-packet-core 0.8.2", + "netlink-packet-route 0.31.0", + "netlink-sys 0.8.8", "objc2", "objc2-core-foundation", "objc2-core-wlan", @@ -6905,6 +6853,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "netdev" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4db4135b187ddae3b6813c4f9d9ac9d5181859fa6a08b4e006c63a130974ed62" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core 0.9.0", + "netlink-packet-route 0.33.0", + "netlink-sys 0.9.0", + "objc2-core-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + [[package]] name = "netlink-packet-core" version = "0.8.2" @@ -6914,6 +6886,12 @@ dependencies = [ "paste", ] +[[package]] +name = "netlink-packet-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6e2daf9a8c2e21302714706860a0e6be02e03d17294ecc66b354ea7d4059dd" + [[package]] name = "netlink-packet-route" version = "0.31.0" @@ -6923,7 +6901,20 @@ dependencies = [ "bitflags 2.13.1", "libc", "log", - "netlink-packet-core", + "netlink-packet-core 0.8.2", +] + +[[package]] +name = "netlink-packet-route" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d48ffc8b73a506e1ff0878528c72668f2bfbc3d875b6be890a96be5d0d5576" +dependencies = [ + "bitflags 2.13.1", + "libc", + "log", + "netlink-packet-core 0.9.0", + "zerocopy", ] [[package]] @@ -6936,8 +6927,8 @@ dependencies = [ "futures-channel", "futures-util", "log", - "netlink-packet-core", - "netlink-sys", + "netlink-packet-core 0.8.2", + "netlink-sys 0.8.8", "thiserror 2.0.20", ] @@ -6955,10 +6946,21 @@ dependencies = [ ] [[package]] -name = "netwatch" -version = "0.19.1" +name = "netlink-sys" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +checksum = "c99d38e00b420df49e940fe0826e667c3dad82ac68eb4c2bbf754c1c14a83a10" +dependencies = [ + "bytes", + "libc", + "log", +] + +[[package]] +name = "netwatch" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39da9cad5f23aa43401f09497d3b280e51b5feba115d9ffbf38ca35d6ff95e96" dependencies = [ "atomic-waker", "bytes", @@ -6970,11 +6972,12 @@ dependencies = [ "n0-error", "n0-future", "n0-watcher", - "netdev", - "netlink-packet-core", - "netlink-packet-route", + "netdev 0.45.0", + "netdev 0.46.2", + "netlink-packet-core 0.8.2", + "netlink-packet-route 0.31.0", "netlink-proto", - "netlink-sys", + "netlink-sys 0.8.8", "noq-udp", "objc2-core-foundation", "objc2-system-configuration", @@ -7037,9 +7040,9 @@ dependencies = [ [[package]] name = "noq" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e4bb6601fa543c110d8957813267d5a8d775a0f8fbaccf1f615d06ba9b10da" +checksum = "f1e6c57353d26be91a242f0ec96073066c4481a03f6be874daafb18902394515" dependencies = [ "bytes", "cfg_aliases", @@ -7059,9 +7062,9 @@ dependencies = [ [[package]] name = "noq-proto" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15" +checksum = "9a46813e306c29c4f86357b6ffa39a8e1c97bb274b9a296a19a56d62e4111225" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -7087,9 +7090,9 @@ dependencies = [ [[package]] name = "noq-udp" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" +checksum = "b56d621ed2c1773b5356ab39cc68c819f8d0103f7493d8661c4a5f5f8ea55f40" dependencies = [ "cfg_aliases", "libc", @@ -7124,9 +7127,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.45.3" +version = "0.45.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0ba32ce43631188586469ba1a4c40bcfa63641f1ad5de89ef77f74d801cc17a" +checksum = "67bacf4195a119bc55d576ec6b54cc103723cb46fff5f907b8bdac8fd29ac4a0" dependencies = [ "base64 0.22.1", "bech32 0.12.0", @@ -7153,7 +7156,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b1fdb9fcba732e32719662afad1b267e50322dbe89e506017ec13f24361bddf" dependencies = [ - "nostr 0.45.3", + "nostr 0.45.4", "opaquerr", ] @@ -7163,7 +7166,7 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa07539e52a71cb91fe0d693facaa298f03fcf9edcd66a521094e18e286e2336" dependencies = [ - "nostr 0.45.3", + "nostr 0.45.4", "opaquerr", ] @@ -7179,7 +7182,7 @@ dependencies = [ "futures", "lru", "negentropy", - "nostr 0.45.3", + "nostr 0.45.4", "nostr-database", "nostr-gossip", "opaquerr", @@ -7205,7 +7208,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7314,16 +7317,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "num_enum" version = "0.7.6" @@ -7365,7 +7358,7 @@ dependencies = [ "chrono", "getrandom 0.2.17", "http 1.5.0", - "rand 0.8.7", + "rand 0.8.8", "reqwest 0.12.28", "serde", "serde_json", @@ -7847,19 +7840,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "p256" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primefield", - "primeorder", - "sha2 0.11.0", -] - [[package]] name = "palette" version = "0.7.7" @@ -8055,7 +8035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset 0.5.7", - "indexmap 2.14.0", + "indexmap 2.14.1", ] [[package]] @@ -8066,7 +8046,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset 0.5.7", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap 2.14.1", ] [[package]] @@ -8115,7 +8095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.7", + "rand 0.8.8", ] [[package]] @@ -8215,7 +8195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", - "indexmap 2.14.0", + "indexmap 2.14.1", "quick-xml 0.41.0", "serde", "time", @@ -8231,7 +8211,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -8244,7 +8224,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -8304,9 +8284,9 @@ dependencies = [ [[package]] name = "portmapper" -version = "0.19.1" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" +checksum = "ca97242f016e090a25330613bc2382aab65eb22f9149dbe84d8f8e711d49a530" dependencies = [ "base64 0.22.1", "bytes", @@ -8403,30 +8383,13 @@ dependencies = [ ] [[package]] -name = "primefield" -version = "0.14.0" +name = "prettyplease" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ - "crypto-bigint", - "crypto-common 0.2.2", - "ff", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - -[[package]] -name = "primeorder" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" -dependencies = [ - "elliptic-curve", - "once_cell", - "primefield", - "serdect", - "wnaf", + "proc-macro2", + "syn 3.0.4", ] [[package]] @@ -8454,7 +8417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" dependencies = [ "futures", - "indexmap 2.14.0", + "indexmap 2.14.1", "nix 0.31.3", "tokio", "tracing", @@ -8512,7 +8475,7 @@ dependencies = [ "multimap", "once_cell", "petgraph 0.7.1", - "prettyplease", + "prettyplease 0.2.37", "prost 0.13.5", "prost-types 0.13.5", "regex", @@ -8531,7 +8494,7 @@ dependencies = [ "log", "multimap", "petgraph 0.8.3", - "prettyplease", + "prettyplease 0.2.37", "prost 0.14.4", "prost-types 0.14.4", "regex", @@ -8827,7 +8790,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8863,9 +8826,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -8888,7 +8851,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -9122,12 +9085,12 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.9" +version = "0.14.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" dependencies = [ "aws-lc-rs", - "pem 3.0.6", + "pem 4.0.0", "rustls-pki-types", "time", "x509-parser", @@ -9382,16 +9345,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" -[[package]] -name = "rfc6979" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" -dependencies = [ - "crypto-bigint", - "hmac 0.13.0", -] - [[package]] name = "ring" version = "0.17.14" @@ -9454,7 +9407,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "indexmap 2.14.0", + "indexmap 2.14.1", "oauth2", "pastey", "pin-project-lite", @@ -9602,7 +9555,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9661,7 +9614,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9820,27 +9773,13 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "sec1" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" -dependencies = [ - "base16ct", - "ctutils", - "der", - "hybrid-array", - "subtle", - "zeroize", -] - [[package]] name = "secp256k1" version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "rand 0.8.7", + "rand 0.8.8", "secp256k1-sys 0.10.1", "serde", ] @@ -9852,7 +9791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes 0.14.101", - "rand 0.8.7", + "rand 0.8.8", "secp256k1-sys 0.10.1", ] @@ -9907,7 +9846,7 @@ dependencies = [ "hkdf 0.12.4", "num", "once_cell", - "rand 0.8.7", + "rand 0.8.8", "serde", "sha2 0.10.9", "zbus", @@ -10046,7 +9985,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "itoa", "memchr", "serde", @@ -10108,7 +10047,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.1", "jiff", "schemars 0.9.0", "schemars 1.2.2", @@ -10136,7 +10075,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "itoa", "ryu", "serde", @@ -10171,7 +10110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if 1.0.4", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -10199,7 +10138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if 1.0.4", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -10231,9 +10170,9 @@ dependencies = [ [[package]] name = "sherpa-onnx" -version = "1.13.5" +version = "1.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352a5dbbd9623e800be27e77de514e87fcfe2120256acc4434e6ea87a56bc885" +checksum = "93440301c4240bd5d83496ab9adeee889be48d9428f9c01c37e5ae4ef33ba9e2" dependencies = [ "serde", "serde_json", @@ -10242,9 +10181,9 @@ dependencies = [ [[package]] name = "sherpa-onnx-sys" -version = "1.13.5" +version = "1.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1541926ecf70b3d806467a2f100d3d879eef7fd34f8b307028bb074871d7493" +checksum = "4d627abc4624c845e628ad87cca4ff9ac265fc643021d155f2721ac589eeb324" dependencies = [ "bzip2", "tar", @@ -10302,10 +10241,6 @@ name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", -] [[package]] name = "simd-adler32" @@ -10340,9 +10275,9 @@ dependencies = [ [[package]] name = "simple-dns" -version = "0.11.3" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +checksum = "2b6f884fa9a8d48101774bfbd3aeb81e968dd22cffd19a372da69f183db22c1a" dependencies = [ "bitflags 2.13.1", ] @@ -10622,7 +10557,7 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hashlink", - "indexmap 2.14.0", + "indexmap 2.14.1", "log", "memchr", "percent-encoding", @@ -11188,7 +11123,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11652,7 +11587,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "serde_core", "serde_spanned", "toml_datetime 0.7.5+spec-1.1.0", @@ -11667,7 +11602,7 @@ version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "serde_core", "serde_spanned", "toml_datetime 1.1.1+spec-1.1.0", @@ -11700,7 +11635,7 @@ version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", @@ -11782,7 +11717,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.14.0", + "indexmap 2.14.1", "pin-project-lite", "slab", "sync_wrapper", @@ -12023,9 +11958,9 @@ dependencies = [ [[package]] name = "tree-sitter-language" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" [[package]] name = "tree-sitter-python" @@ -12122,9 +12057,9 @@ dependencies = [ [[package]] name = "twox-hash" -version = "2.1.3" +version = "2.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" [[package]] name = "typenum" @@ -12355,9 +12290,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.25.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "atomic", "getrandom 0.4.3", @@ -12709,9 +12644,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.5" +version = "8.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" dependencies = [ "libc", ] @@ -12757,7 +12692,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -13242,19 +13177,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.20", - "windows 0.62.2", - "windows-core 0.62.2", -] - -[[package]] -name = "wnaf" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" -dependencies = [ - "ff", - "group", - "hybrid-array", + "windows 0.61.3", + "windows-core 0.61.2", ] [[package]] @@ -13586,7 +13510,7 @@ dependencies = [ "hex", "nix 0.29.0", "ordered-stream", - "rand 0.8.7", + "rand 0.8.8", "serde", "serde_repr", "sha1 0.10.7", @@ -13751,12 +13675,18 @@ dependencies = [ "crossbeam-utils", "displaydoc", "flate2", - "indexmap 2.14.0", + "indexmap 2.14.1", "memchr", "thiserror 2.0.20", "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 3d951d95ca..05bc4f2433 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,7 +78,7 @@ postcard = { version = "1", default-features = false, features = ["use-std"] # Inter-relay mesh transport (buzz-relay-mesh) iroh = { version = "1.0.0-rc.0", default-features = false, features = ["tls-ring"] } -serde_json = "1" +serde_json = { version = "1", features = ["raw_value"] } serde_yaml = "0.9" evalexpr = "11" cron = "0.16" @@ -101,6 +101,9 @@ anyhow = "1" uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } +# JWT / JWS verification (NIP-FI federated identity assertions) +jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] } + # HTTP client (webhook delivery) reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } diff --git a/Justfile b/Justfile index 59da146e96..631831c781 100644 --- a/Justfile +++ b/Justfile @@ -347,7 +347,12 @@ test-unit: # capabilities without linking goose. Enumerated explicitly because # nothing in CI runs `cargo test --workspace`; without this step a # manifest edit that diverges Rust from the corpus ships green. - cargo nextest run -p buzz-agent --lib + # buzz-agent's Buzz-owned loop contracts live in both src unit tests + # and infra-free tests/ integration tests (ACP stdio, cancellation, + # steering, permissions, hooks, skills). The real-dev-MCP tests launch + # the sibling binary directly, so build that fixture first. + cargo build -p buzz-dev-mcp + cargo nextest run -p buzz-agent cargo nextest run -p buzz-model-catalog --lib else ./scripts/run-tests.sh unit diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index f39ecc33c5..bc22f61561 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -6,9 +6,17 @@ import { useMemo, useState, } from "react"; -import { ApiFailure, request } from "./api"; +import { + type AuthMode, + ApiFailure, + mutate, + probeAuthMode, + request, + requestObjectUrl, +} from "./api"; import type { FeedbackDetail, + FeedbackStatus, FeedbackSummary, Report, ReportDetail as ReportDetailData, @@ -85,9 +93,9 @@ function StateView({ return resource.data ? children(resource.data) : null; } -function Reports() { +function Reports({ authMode }: { authMode: AuthMode }) { const resource = useResource( - () => request("/reports?status=open&limit=100"), + () => request("/reports?status=open&limit=100", authMode), "reports", ); return ( @@ -135,9 +143,9 @@ function Reports() { ); } -function ReportDetail({ id }: { id: string }) { +function ReportDetail({ id, authMode }: { id: string; authMode: AuthMode }) { const resource = useResource( - () => request(`/reports/${id}`), + () => request(`/reports/${id}`, authMode), id, ); return ( @@ -210,28 +218,29 @@ function ReportDetail({ id }: { id: string }) { ); } -function FeedbackList() { +function FeedbackList({ authMode }: { authMode: AuthMode }) { const resource = useResource( - () => request("/feedback"), + () => request("/feedback", authMode), "feedback", ); const [query, setQuery] = useState(""); const [community, setCommunity] = useState("all"); const [timeRange, setTimeRange] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); - const [statuses, setStatuses] = useState(loadFeedbackStatuses); + // Successful PATCH responses override the server-loaded status so the row + // reflects the new value without a full refetch. Keyed by feedback id. + const [overrides, setOverrides] = useState>( + {}, + ); - const updateStatus = (id: string, event: ChangeEvent) => { - const checked = event.target.checked; - setStatuses((current) => { - const next = { - ...current, - [id]: checked, - }; - saveFeedbackStatuses(next); - return next; - }); - }; + // Mutations require a named principal; disabled mode's server rejects them + // (probe reports canAct: false), so the write control is hidden there rather + // than offering an action that can only fail. + const canWrite = authMode === "nip98"; + + const applyStatus = useCallback((id: string, status: FeedbackStatus) => { + setOverrides((current) => ({ ...current, [id]: status })); + }, []); return ( {({ communities, filtered }) => ( <> @@ -301,8 +310,9 @@ function FeedbackList() { } > - - + + + @@ -327,22 +337,18 @@ function FeedbackList() { {item.bodySummary} - {item.communityHost} + {short(item.submitterPubkey)} - +
Received @@ -352,7 +358,7 @@ function FeedbackList() { className="record-open-link" > - Open feedback from {item.communityHost} + Open feedback from {hostLabel(item.communityHost)} @@ -372,13 +378,101 @@ function FeedbackList() { ); } +const STATUS_LABELS: Record = { + new: "New", + reviewed: "Reviewed", + archived: "Archived", +}; + +const STATUS_OPTIONS: FeedbackStatus[] = ["new", "reviewed", "archived"]; + +/// The per-row lifecycle control. In writable mode it PATCHes the relay and +/// adopts the status from the PATCH response (never optimistically — a failed +/// write leaves the prior status and surfaces an error). In read-only mode it +/// shows the authoritative status as a static badge, since disabled-mode +/// mutations are rejected server-side. +function FeedbackStatusControl({ + id, + status, + authMode, + canWrite, + onApplied, +}: { + id: string; + status: FeedbackStatus; + authMode: AuthMode; + canWrite: boolean; + onApplied: (id: string, status: FeedbackStatus) => void; +}) { + const [pending, setPending] = useState(false); + const [error, setError] = useState(false); + + if (!canWrite) { + return ( + + + {STATUS_LABELS[status]} + + + ); + } + + const onChange = async (event: ChangeEvent) => { + const next = event.target.value as FeedbackStatus; + setPending(true); + setError(false); + try { + const updated = await mutate<{ status: FeedbackStatus }>( + `/feedback/${encodeURIComponent(id)}`, + "PATCH", + { status: next }, + authMode, + ); + onApplied(id, updated.status); + } catch { + setError(true); + } finally { + setPending(false); + } + }; + + return ( + + ); +} + +/// Feedback whose source community was purged carries no host. Render an +/// explicit provenance-unavailable marker rather than an empty slot. +function Provenance({ host }: { host: string | null }) { + if (host) return host; + return Community unavailable; +} + +function hostLabel(host: string | null): string { + return host ?? "an unavailable community"; +} + function FeedbackResults({ items, query, community, timeRange, statusFilter, - statuses, + overrides, children, }: { items: FeedbackSummary[]; @@ -386,7 +480,7 @@ function FeedbackResults({ community: string; timeRange: string; statusFilter: string; - statuses: FeedbackStatuses; + overrides: Record; children: (results: { communities: string[]; filtered: FeedbackSummary[]; @@ -394,14 +488,14 @@ function FeedbackResults({ }) { const results = useMemo(() => { const communities = [...new Set(items.map((item) => item.communityHost))] - .filter(Boolean) + .filter((host): host is string => Boolean(host)) .sort((left, right) => left.localeCompare(right)); const normalizedQuery = query.trim().toLocaleLowerCase(); const after = timeRangeStart(timeRange); const filtered = items.filter((item) => { + const status = overrides[item.id] ?? item.status; if (community !== "all" && item.communityHost !== community) return false; - if (statusFilter === "pending" && statuses[item.id]) return false; - if (statusFilter === "acted-on" && !statuses[item.id]) return false; + if (statusFilter !== "all" && status !== statusFilter) return false; if (after !== undefined) { const receivedAt = new Date(item.receivedAt).valueOf(); if (Number.isNaN(receivedAt) || receivedAt < after) return false; @@ -409,19 +503,25 @@ function FeedbackResults({ if (!normalizedQuery) return true; return [ item.bodySummary, - item.communityHost, + item.communityHost ?? "", item.category ?? "uncategorized", item.submitterPubkey, ].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)); }); return { communities, filtered }; - }, [items, query, community, timeRange, statusFilter, statuses]); + }, [items, query, community, timeRange, statusFilter, overrides]); return children(results); } -function FeedbackDetailView({ id }: { id: string }) { +function FeedbackDetailView({ + id, + authMode, +}: { + id: string; + authMode: AuthMode; +}) { const resource = useResource( - () => request(`/feedback/${id}`), + () => request(`/feedback/${id}`, authMode), id, ); return ( @@ -434,11 +534,16 @@ function FeedbackDetailView({ id }: { id: string }) { > {(feedback) => { - const attachments = feedbackAttachments( - feedback.id, - feedback.tags, - feedback.communityHost, - ); + // Without an authoritative host we cannot validate attachment URLs or + // derive their fetch paths safely, so we render none and mark the + // provenance unavailable rather than guessing an origin. + const attachments = feedback.communityHost + ? feedbackAttachments( + feedback.id, + feedback.tags, + feedback.communityHost, + ) + : []; const body = stripAttachmentMarkdown(feedback.body, attachments); return (
@@ -448,7 +553,9 @@ function FeedbackDetailView({ id }: { id: string }) {
-

{feedback.communityHost}

+

+ +

@@ -460,8 +567,9 @@ function FeedbackDetailView({ id }: { id: string }) {
{attachments.map((attachment) => ( ))}
@@ -488,10 +596,8 @@ function FeedbackDetailView({ id }: { id: string }) { ); } -type FeedbackStatuses = Record; - interface FeedbackAttachment { - url: string; + path: string; sourceUrl: string; mimeType: string; hash: string; @@ -500,25 +606,6 @@ interface FeedbackAttachment { filename?: string; } -const FEEDBACK_STATUS_KEY = "buzz-admin-feedback-status"; - -function loadFeedbackStatuses(): FeedbackStatuses { - try { - const stored = localStorage.getItem(FEEDBACK_STATUS_KEY); - return stored ? (JSON.parse(stored) as FeedbackStatuses) : {}; - } catch { - return {}; - } -} - -function saveFeedbackStatuses(statuses: FeedbackStatuses) { - try { - localStorage.setItem(FEEDBACK_STATUS_KEY, JSON.stringify(statuses)); - } catch { - // The controls remain useful for the current session if storage is blocked. - } -} - function timeRangeStart(range: string) { const durations: Record = { day: 24 * 60 * 60 * 1000, @@ -551,7 +638,7 @@ function feedbackAttachments( const parsedSize = Number(values.get("size")); return [ { - url: `/api/admin/v1/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`, + path: `/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`, sourceUrl: safeUrl, mimeType, hash, @@ -595,8 +682,17 @@ function stripAttachmentMarkdown( .trim(); } -function Attachment({ attachment }: { attachment: FeedbackAttachment }) { - const url = attachment.url; +function Attachment({ + attachment, + authMode, +}: { + attachment: FeedbackAttachment; + authMode: AuthMode; +}) { + const [objectUrl, setObjectUrl] = useState(); + const [objectType, setObjectType] = useState(); + const [failed, setFailed] = useState(false); + const path = attachment.path; const name = attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`; const metadata = [ @@ -607,33 +703,74 @@ function Attachment({ attachment }: { attachment: FeedbackAttachment }) { .filter(Boolean) .join(" · "); - if (attachment.mimeType.startsWith("image/")) { + useEffect(() => { + // The API requires an Authorization header, so the bytes are fetched here + // and handed to the DOM as an object URL revoked on replacement/unmount. + let url: string | undefined; + let active = true; + setObjectUrl(undefined); + setObjectType(undefined); + setFailed(false); + requestObjectUrl(path, authMode) + .then((created) => { + if (!active) { + URL.revokeObjectURL(created.url); + return; + } + url = created.url; + setObjectUrl(created.url); + setObjectType(created.type); + }) + .catch(() => { + if (active) setFailed(true); + }); + return () => { + active = false; + if (url) URL.revokeObjectURL(url); + }; + }, [path, authMode]); + + const detail = failed ? "Could not load attachment" : metadata; + + // Inline rendering is gated on the server-VERIFIED blob type, never the + // reporter-supplied `attachment.mimeType`: the relay only labels sniffed + // passive raster images as `image/*` and forces everything else to + // `application/octet-stream`, so a hostile payload can never render inline. + if (objectUrl && objectType?.startsWith("image/")) { return (
- - {name} + + {name}
{name} - {metadata} + {detail}
); } - return ( - + const label = ( + <> {name} - {metadata} + {detail} + + ); + + // Until the bytes are fetched there is nothing a link could point at: the + // API path itself would open unauthenticated in a new tab. + if (!objectUrl) return
{label}
; + + // Non-image (or not-yet-typed) payloads are download-only. The object URL + // already wraps `application/octet-stream` bytes, and the `download` + // attribute with no `target="_blank"` means clicking saves the file rather + // than navigating to a typed document on the admin origin. + return ( +
+ {label} ); @@ -797,18 +934,72 @@ function ArrowIcon() { ); } +/// Shown in nip98 mode when no NIP-07 extension is available. Instructs the +/// operator to install nos2x or Alby before continuing. +function Nip07Screen() { + return ( +
+
+

Nostr extension required

+

+ This relay uses NIP-98 HTTP Auth. Install a NIP-07 browser extension + such as{" "} + + nos2x + {" "} + or{" "} + + Alby + + , then reload this page. Your Nostr key will be used to sign each + request. +

+ +
+
+ ); +} + export function App() { const { path } = usePath(); + + // Probe the relay once to discover the auth mode. `null` means the probe is + // still in flight. Once resolved, the mode is stable for the session. + const [authMode, setAuthMode] = useState(null); + useEffect(() => { + let active = true; + probeAuthMode().then((mode) => { + if (active) setAuthMode(mode); + }); + return () => { + active = false; + }; + }, []); + const report = path.match(/^\/reports\/([^/]+)$/); const feedback = path.match(/^\/feedback\/([^/]+)$/); + + // Probe still in flight — render nothing to avoid a visible flash. + if (authMode === null) return null; + + // NIP-98 mode: require a NIP-07 extension. + if (authMode === "nip98" && !(window as Window & { nostr?: unknown }).nostr) + return ; + const content = report ? ( - + ) : feedback ? ( - + ) : path === "/feedback" ? ( - + ) : ( - + ); return (
diff --git a/admin-web/src/api.ts b/admin-web/src/api.ts index 9e5aa7569f..e096dcbf3b 100644 --- a/admin-web/src/api.ts +++ b/admin-web/src/api.ts @@ -9,11 +9,114 @@ export class ApiFailure extends Error { } } -export async function request(path: string): Promise { - const response = await fetch(`${PREFIX}${path}`, { - credentials: "same-origin", - headers: { accept: "application/json" }, +/// The authentication mode the relay requires, discovered via the probe. +/// - `nip98` — NIP-98 HTTP Auth; each request signed with a NIP-07 extension +/// - `disabled` — relay returned 200 with no auth; no credential needed +export type AuthMode = "nip98" | "disabled"; + +/// Sign a NIP-98 kind-27235 event for the given URL + method via window.nostr. +/// Throws if window.nostr is not available or signing fails. +/// +/// A fresh random `nonce` tag is generated on every call. Without it, two +/// same-URL requests in the same second produce byte-identical events (the +/// signed fields are only `u`, `method`, and `created_at` at 1-second +/// resolution), so their event IDs collide — the relay's NIP-98 replay guard +/// rejects the second, and the 401 retry re-signs the same fields and can +/// never recover. The verifier ignores unknown tags, so the nonce is inert to +/// verification and serves only to make each signed event unique. +/// +/// For body-bearing methods the caller passes `body`; a `payload` tag carrying +/// the hex SHA-256 of the exact bytes is added and signed. The relay's verifier +/// rejects a body-bearing request whose `payload` tag is absent or mismatched +/// (auth.rs), so the hash must be over the identical bytes the request sends. +async function signNip98( + url: string, + method: string, + body?: Uint8Array, +): Promise { + const nostr = (window as Window & typeof globalThis & { nostr?: Nostr98 }) + .nostr; + if (!nostr) throw new Error("No NIP-07 extension available"); + const nonce = crypto.getRandomValues(new Uint8Array(16)); + const nonceHex = toHex(nonce); + const tags: string[][] = [ + ["u", url], + ["method", method], + ["nonce", nonceHex], + ]; + if (body !== undefined) { + tags.push(["payload", await sha256Hex(body)]); + } + const event = await nostr.signEvent({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags, + content: "", }); + return `Nostr ${btoa(JSON.stringify(event))}`; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource); + return toHex(new Uint8Array(digest)); +} + +// Minimal type for the NIP-07 window.nostr interface. +interface Nostr98 { + signEvent(event: { + kind: number; + created_at: number; + tags: string[][]; + content: string; + }): Promise>; +} + +/// Every admin API call goes through here. Attaches the correct credential +/// for the current auth mode: +/// - `nip98` mode: sign a kind-27235 event via NIP-07 for each request +/// - `disabled` mode: no credential +/// +/// A 401 in nip98 mode re-signs once (handles key rotation / clock skew) +/// and retries the request exactly once; a second 401 surfaces the error — +/// no infinite loop. +async function send( + path: string, + accept: string, + authMode: AuthMode, + init?: { method?: string; body?: Uint8Array; contentType?: string }, +): Promise { + const method = init?.method ?? "GET"; + const body = init?.body; + const doRequest = async () => { + const headers: Record = { accept }; + if (init?.contentType) headers["content-type"] = init.contentType; + if (authMode === "nip98") { + const url = `${location.protocol}//${location.host}${PREFIX}${path}`; + headers.authorization = await signNip98(url, method, body); + } + return fetch(`${PREFIX}${path}`, { + method, + credentials: "same-origin", + headers, + body: body as BodyInit | undefined, + }); + }; + + let response = await doRequest(); + + if (response.status === 401 && authMode === "nip98") { + // Re-sign with a fresh event and retry exactly once (handles clock + // skew or key rotation). A second 401 surfaces the error below. + response = await doRequest(); + } + + if (response.status === 401) { + throw new ApiFailure(401, "The admin credential was rejected."); + } if (!response.ok) { const envelope = await response.json().catch(() => null); throw new ApiFailure( @@ -21,5 +124,62 @@ export async function request(path: string): Promise { envelope?.error?.message ?? `Request failed (${response.status})`, ); } + return response; +} + +export async function request(path: string, authMode: AuthMode): Promise { + const response = await send(path, "application/json", authMode); return response.json() as Promise; } + +/// Send a body-bearing mutation (PATCH/PUT/POST) and parse the JSON response. +/// The body is serialized once and signed over those exact bytes so the NIP-98 +/// `payload` tag matches what the relay verifies. +export async function mutate( + path: string, + method: string, + body: unknown, + authMode: AuthMode, +): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(body)); + const response = await send(path, "application/json", authMode, { + method, + body: bytes, + contentType: "application/json", + }); + return response.json() as Promise; +} + +/// Probe whether the relay requires authentication. +/// Issues one unauthenticated request: +/// - 200 → `disabled` (no auth needed) +/// - anything else → `nip98` (the only authenticated mode; fail-secure) +export async function probeAuthMode(): Promise { + try { + const response = await fetch(`${PREFIX}/reports`, { + credentials: "same-origin", + headers: { accept: "application/json" }, + }); + return response.status === 200 ? "disabled" : "nip98"; + } catch { + // Network error — default to nip98 so a credential is required. + return "nip98"; + } +} + +/// Attachments cannot be fetched by `` or `` because those +/// carry no Authorization header. Callers render the object URL and must +/// revoke it when it is replaced or unmounted. +/// +/// Returns the server-verified `type` alongside the URL. The relay sniffs the +/// stored bytes and only labels verified passive raster images as `image/*`; +/// everything else is `application/octet-stream`. Callers must decide inline +/// rendering from this type, never from the untrusted reporter-supplied MIME. +export async function requestObjectUrl( + path: string, + authMode: AuthMode, +): Promise<{ url: string; type: string }> { + const response = await send(path, "*/*", authMode); + const blob = await response.blob(); + return { url: URL.createObjectURL(blob), type: blob.type }; +} diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index 8f0ba3d6c3..c55b033a66 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -304,14 +304,31 @@ h1 { gap: 0.4rem; color: rgb(35 30 30 / 62%); font-size: 0.78rem; +} + +.feedback-status select { + font: inherit; + padding: 0.25rem 0.4rem; + border-radius: 0.4rem; + border: 1px solid rgb(35 30 30 / 18%); + background: #fff; + color: #231e1e; cursor: pointer; } -.feedback-status input { - width: 1rem; - height: 1rem; - margin: 0; - accent-color: #231e1e; +.feedback-status select:disabled { + opacity: 0.55; + cursor: progress; +} + +.status-error { + color: #b3261e; + font-size: 0.72rem; +} + +.provenance-unavailable { + font-style: italic; + color: rgb(35 30 30 / 45%); } .record-open-link { @@ -670,6 +687,18 @@ dd { color: #9f2424; } +.auth-prompt button { + margin-top: 1rem; +} + +.attachment-placeholder { + min-height: 8rem; + display: grid; + place-content: center; + color: rgb(35 30 30 / 48%); + font-size: 0.8rem; +} + @media (max-width: 720px) { .app-header { width: min(100% - 2rem, 1120px); diff --git a/admin-web/src/types.ts b/admin-web/src/types.ts index 6c10837758..042ec434bf 100644 --- a/admin-web/src/types.ts +++ b/admin-web/src/types.ts @@ -23,25 +23,33 @@ export interface ReportDetail extends Report { message: ReportedMessage | null; } +export type FeedbackStatus = "new" | "reviewed" | "archived"; + export interface FeedbackSummary { id: string; - communityId: string; - communityHost: string; + /// `null` once the source community is purged (provenance severed). + communityId: string | null; + /// `null` when `communityId` is severed — feedback retained without origin. + communityHost: string | null; submitterPubkey: string; category?: string; bodySummary: string; + status: FeedbackStatus; receivedAt: string; } export interface FeedbackDetail { id: string; - communityId: string; - communityHost: string; + /// `null` once the source community is purged (provenance severed). + communityId: string | null; + /// `null` when `communityId` is severed — feedback retained without origin. + communityHost: string | null; eventId: string; submitterPubkey: string; category?: string; body: string; tags: string[][]; + status: FeedbackStatus; eventCreatedAt: string; receivedAt: string; } diff --git a/admin-web/tests/auth.spec.ts b/admin-web/tests/auth.spec.ts new file mode 100644 index 0000000000..7f9ca37637 --- /dev/null +++ b/admin-web/tests/auth.spec.ts @@ -0,0 +1,521 @@ +import { expect, type Page, test } from "@playwright/test"; + +interface ObjectUrlLog { + created: string[]; + revoked: string[]; +} + +declare global { + interface Window { + objectUrlLog: ObjectUrlLog; + } +} + +/// Injects a minimal window.nostr stub that returns a fake signed event, so a +/// test can drive nip98 mode without a real NIP-07 extension. The stub derives +/// the event `id` from the signed fields (tags + created_at + content), so two +/// signings collide iff their signed payloads are byte-identical — exactly the +/// property the relay's replay guard keys on. +async function seedNip98(page: Page) { + await page.addInitScript(() => { + (window as Window & { nostr?: unknown }).nostr = { + signEvent: async (event: { + kind: number; + created_at: number; + tags: string[][]; + content: string; + }) => { + const serialized = JSON.stringify([ + event.kind, + event.created_at, + event.tags, + event.content, + ]); + // Cheap non-crypto digest of the signed fields, hex-padded to 64 chars. + let h = 0; + for (let i = 0; i < serialized.length; i++) { + h = (Math.imul(31, h) + serialized.charCodeAt(i)) | 0; + } + const id = (h >>> 0).toString(16).padStart(8, "0").repeat(8); + return { + ...event, + id, + pubkey: "b".repeat(64), + sig: "c".repeat(128), + }; + }, + }; + }); +} + +/// Decode an `Authorization: Nostr ` header to the signed event. +function decodeNostrHeader(header: string): { id: string; tags: string[][] } { + return JSON.parse(atob(header.replace(/^Nostr /, ""))); +} + +test("nip98 mode: attachments are fetched with a signed credential and rendered from blob urls", async ({ + page, +}) => { + const id = "feedback-with-attachments"; + const imageHash = "a".repeat(64); + const fileHash = "b".repeat(64); + const imageUrl = `https://design.buzz.xyz/media/${imageHash}.png`; + const fileUrl = `https://design.buzz.xyz/media/${fileHash}.txt`; + await seedNip98(page); + + const attachmentRequests: { path: string; authorization?: string }[] = []; + await page.route(`**/api/admin/v1/feedback/${id}/attachments/**`, (route) => { + attachmentRequests.push({ + path: new URL(route.request().url()).pathname, + authorization: route.request().headers().authorization, + }); + route.fulfill({ contentType: "application/octet-stream", body: "bytes" }); + }); + await page.route(`**/api/admin/v1/feedback/${id}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id, + communityId: "one", + communityHost: "design.buzz.xyz", + eventId: "31".repeat(32), + submitterPubkey: "21".repeat(32), + category: "bug", + body: "Composer froze.", + tags: [ + [ + "imeta", + `url ${imageUrl}`, + "m image/png", + `x ${imageHash}`, + "filename screenshot.png", + ], + [ + "imeta", + `url ${fileUrl}`, + "m text/plain", + `x ${fileHash}`, + "filename diagnostics.txt", + ], + ], + eventCreatedAt: "2026-07-17T17:25:00Z", + receivedAt: "2026-07-17T17:30:00Z", + }), + }), + ); + + await page.goto(`/feedback/${id}`); + await expect( + page.getByRole("img", { name: "screenshot.png" }), + ).toHaveAttribute("src", /^blob:/); + await expect( + page.getByRole("link", { name: /diagnostics.txt/ }), + ).toHaveAttribute("href", /^blob:/); + + expect(attachmentRequests.map((request) => request.path).sort()).toEqual( + [ + `/api/admin/v1/feedback/${id}/attachments/${imageHash}`, + `/api/admin/v1/feedback/${id}/attachments/${fileHash}`, + ].sort(), + ); + for (const request of attachmentRequests) { + expect(request.authorization).toMatch(/^Nostr /); + } +}); + +/// Records every object URL the SPA creates and revokes, so a test can prove a +/// blob handed to the DOM is released rather than merely replaced. +async function instrumentObjectUrls(page: Page) { + await page.addInitScript(() => { + const log: ObjectUrlLog = { created: [], revoked: [] }; + window.objectUrlLog = log; + const create = URL.createObjectURL.bind(URL); + const revoke = URL.revokeObjectURL.bind(URL); + URL.createObjectURL = (source: Blob | MediaSource) => { + const url = create(source); + log.created.push(url); + return url; + }; + URL.revokeObjectURL = (url: string) => { + log.revoked.push(url); + revoke(url); + }; + }); +} + +const FEEDBACK_ID = "feedback-with-attachments"; +const IMAGE_HASH = "a".repeat(64); +const FILE_HASH = "b".repeat(64); + +/// A feedback detail carrying one image and one non-image attachment. The +/// probe to `/reports` returns 200 so the SPA runs in disabled mode: these +/// tests exercise object-URL lifecycle, not authentication. +async function routeFeedbackDetail(page: Page) { + const host = "design.buzz.xyz"; + await page.route(`**/api/admin/v1/reports**`, (route) => + route.fulfill({ contentType: "application/json", body: "[]" }), + ); + await page.route(`**/api/admin/v1/feedback?**`, (route) => + route.fulfill({ contentType: "application/json", body: "[]" }), + ); + await page.route(`**/api/admin/v1/feedback/${FEEDBACK_ID}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id: FEEDBACK_ID, + communityId: "one", + communityHost: host, + eventId: "31".repeat(32), + submitterPubkey: "21".repeat(32), + category: "bug", + body: "Composer froze.", + tags: [ + [ + "imeta", + `url https://${host}/media/${IMAGE_HASH}.png`, + "m image/png", + `x ${IMAGE_HASH}`, + "filename screenshot.png", + ], + [ + "imeta", + `url https://${host}/media/${FILE_HASH}.txt`, + "m text/plain", + `x ${FILE_HASH}`, + "filename diagnostics.txt", + ], + ], + eventCreatedAt: "2026-07-17T17:25:00Z", + receivedAt: "2026-07-17T17:30:00Z", + }), + }), + ); +} + +test("attachment object urls are revoked when the view is left", async ({ + page, +}) => { + await instrumentObjectUrls(page); + await routeFeedbackDetail(page); + await page.route( + `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`, + (route) => + route.fulfill({ contentType: "application/octet-stream", body: "bytes" }), + ); + + await page.goto(`/feedback/${FEEDBACK_ID}`); + const imageUrl = await page + .getByRole("img", { name: "screenshot.png" }) + .getAttribute("src"); + const fileUrl = await page + .getByRole("link", { name: /diagnostics.txt/ }) + .getAttribute("href"); + expect(imageUrl).toMatch(/^blob:/); + expect(fileUrl).toMatch(/^blob:/); + expect(await page.evaluate(() => window.objectUrlLog.revoked)).toEqual([]); + + await page.getByRole("link", { name: "Back to feedback" }).click(); + await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible(); + + await expect + .poll(() => page.evaluate(() => window.objectUrlLog.revoked)) + .toEqual(expect.arrayContaining([imageUrl, fileUrl])); +}); + +test("an attachment that arrives after the view is left is revoked immediately", async ({ + page, +}) => { + await instrumentObjectUrls(page); + await routeFeedbackDetail(page); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route( + `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`, + async (route) => { + await held; + await route.fulfill({ + contentType: "application/octet-stream", + body: "bytes", + }); + }, + ); + + await page.goto(`/feedback/${FEEDBACK_ID}`); + // Both fetches are held, so no blob exists yet. + await expect(page.getByText("Loading…")).toBeVisible(); + expect(await page.evaluate(() => window.objectUrlLog.created)).toEqual([]); + + // Leave before either fetch resolves, then let both complete. + await page.getByRole("link", { name: "Back to feedback" }).click(); + await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible(); + release(); + + await expect + .poll(() => page.evaluate(() => window.objectUrlLog.revoked.length)) + .toBe(2); + const log = await page.evaluate(() => window.objectUrlLog); + expect(log.revoked.sort()).toEqual(log.created.sort()); + // Nothing was ever handed to the DOM: the blobs outlived their view. + await expect(page.getByRole("img", { name: "screenshot.png" })).toHaveCount( + 0, + ); +}); + +test("probe: disabled mode renders directly when the probe returns 200", async ({ + page, +}) => { + // The probe to /api/admin/v1/reports returns 200, indicating the relay runs + // in disabled mode. The dashboard must render directly with no credential. + await page.route("**/api/admin/v1/reports**", (route) => + route.fulfill({ contentType: "application/json", body: "[]" }), + ); + + await page.goto("/reports"); + + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Nostr extension required" }), + ).toHaveCount(0); +}); + +test("probe: nip98 mode without a NIP-07 extension shows the installation screen", async ({ + page, +}) => { + // The probe returns 401 and window.nostr is NOT injected, so the dashboard + // must show the extension installation screen instead of the dashboard. + await page.route("**/api/admin/v1/**", (route) => + route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "nip98 required" }, + }), + }), + ); + + await page.goto("/reports"); + + await expect( + page.getByRole("heading", { name: "Nostr extension required" }), + ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Open reports" })).toHaveCount( + 0, + ); +}); + +test("probe: nip98 mode with a mocked NIP-07 extension signs requests and renders the dashboard", async ({ + page, +}) => { + await seedNip98(page); + + const authorizationHeaders: (string | undefined)[] = []; + await page.route("**/api/admin/v1/**", async (route) => { + const headers = route.request().headers(); + authorizationHeaders.push(headers.authorization); + // Probe: return 401 to trigger nip98 mode detection. + if (!headers.authorization) { + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "nip98 required" }, + }), + }); + } else { + // Any Authorization: Nostr header → accept. + await route.fulfill({ contentType: "application/json", body: "[]" }); + } + }); + + await page.goto("/reports"); + + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Nostr extension required" }), + ).toHaveCount(0); + // The authenticated request used Authorization: Nostr. + const authenticatedHeaders = authorizationHeaders.filter(Boolean); + expect(authenticatedHeaders.length).toBeGreaterThan(0); + for (const h of authenticatedHeaders) { + expect(h).toMatch(/^Nostr /); + } +}); + +test("nip98 mode: same-second retry re-signs with a distinct event id", async ({ + page, +}) => { + // Freeze the clock so both signings share created_at (1s resolution). With + // only u+method+created_at signed, the two events would be byte-identical + // and collide in the relay's replay guard, so the 401 retry could never + // recover. The per-signing random nonce tag must make the second event's id + // distinct despite the frozen clock. + await page.addInitScript(() => { + const FROZEN = 1_760_000_000_000; + const RealDate = Date; + // biome-ignore lint/suspicious/noExplicitAny: minimal Date shim for the test + (globalThis as any).Date = class extends RealDate { + constructor(...args: unknown[]) { + // biome-ignore lint/suspicious/noExplicitAny: forward constructor args + super(...(args.length ? (args as any) : [FROZEN])); + } + static now() { + return FROZEN; + } + }; + }); + await seedNip98(page); + + const authCalls: string[] = []; + let signCount = 0; + await page.route("**/api/admin/v1/**", async (route) => { + const headers = route.request().headers(); + if (!headers.authorization) { + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "nip98 required" }, + }), + }); + return; + } + authCalls.push(headers.authorization); + signCount++; + // First authenticated attempt → reject, forcing the re-sign + retry. + await route.fulfill( + signCount === 1 + ? { + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "rejected" }, + }), + } + : { contentType: "application/json", body: "[]" }, + ); + }); + + await page.goto("/reports"); + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + await page.waitForLoadState("networkidle"); + + expect(authCalls).toHaveLength(2); + const [first, second] = authCalls.map(decodeNostrHeader); + // Same frozen created_at, yet distinct ids — the nonce tag did its job. + expect(first.id).not.toBe(second.id); + const nonceOf = (tags: string[][]) => tags.find((t) => t[0] === "nonce")?.[1]; + expect(nonceOf(first.tags)).toBeTruthy(); + expect(nonceOf(second.tags)).toBeTruthy(); + expect(nonceOf(first.tags)).not.toBe(nonceOf(second.tags)); +}); + +test("nip98 mode: first-401-then-200 retries once and renders the dashboard", async ({ + page, +}) => { + // Models a credential that is momentarily rejected (clock skew, key + // rotation) then accepted on the second attempt. + let signCount = 0; + await seedNip98(page); + + const authCalls: string[] = []; + await page.route("**/api/admin/v1/**", async (route) => { + const headers = route.request().headers(); + if (!headers.authorization) { + // Probe — announce nip98 mode. + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "nip98 required" }, + }), + }); + return; + } + authCalls.push(headers.authorization); + signCount++; + if (signCount === 1) { + // First authenticated attempt → reject. + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "rejected" }, + }), + }); + } else { + // Second attempt → accept. + await route.fulfill({ contentType: "application/json", body: "[]" }); + } + }); + + await page.goto("/reports"); + + // The retry should succeed. Wait for network to settle (both attempts + // complete) before asserting the authCalls count. + await page.waitForLoadState("networkidle"); + // Exactly two distinct Nostr credentials were sent (one per attempt). + expect(authCalls).toHaveLength(2); + for (const h of authCalls) { + expect(h).toMatch(/^Nostr /); + } +}); + +test("nip98 mode: persistent 401 surfaces error after exactly one retry", async ({ + page, +}) => { + // Every authenticated request returns 401. The SPA must attempt exactly + // two requests (first attempt + one retry) and then surface the error — + // never a third attempt. + await seedNip98(page); + + const authCalls: string[] = []; + await page.route("**/api/admin/v1/**", async (route) => { + const headers = route.request().headers(); + if (!headers.authorization) { + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "nip98 required" }, + }), + }); + return; + } + authCalls.push(headers.authorization); + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "rejected" }, + }), + }); + }); + + await page.goto("/reports"); + + // After the retry fails, the error state renders. The StateView shows + // "Could not load data" inside a role=alert region. + await expect(page.getByRole("alert")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Could not load data" }), + ).toBeVisible(); + // Exactly two Nostr credentials sent — no third attempt. + await page.waitForLoadState("networkidle"); + expect(authCalls).toHaveLength(2); +}); diff --git a/admin-web/tests/csp.spec.ts b/admin-web/tests/csp.spec.ts new file mode 100644 index 0000000000..7cc0d830c1 --- /dev/null +++ b/admin-web/tests/csp.spec.ts @@ -0,0 +1,105 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; + +const ROUTER_RS = fileURLToPath( + new URL("../../crates/buzz-relay/src/router.rs", import.meta.url), +); + +/// The exact policy the relay serves on admin SPA documents. Read from the +/// relay source rather than copied, so this test can never pass against a +/// policy operators do not actually get. The preview server used here serves +/// no CSP of its own, so it is injected below. +function adminCsp() { + const source = readFileSync(ROUTER_RS, "utf8"); + const match = source.match(/const ADMIN_CSP: &str = "([^"]+)";/); + if (!match) throw new Error(`ADMIN_CSP not found in ${ROUTER_RS}`); + return match[1]; +} + +test("the relay admin csp does not break the built dashboard", async ({ + page, +}) => { + const csp = adminCsp(); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).not.toContain("unsafe-inline"); + + // Every admin API call returns 200, so the probe resolves to disabled mode + // and the dashboard renders without a credential. + await page.route("**/api/admin/v1/**", (route) => + route.fulfill({ contentType: "application/json", body: "[]" }), + ); + // Only the document request: the API call to /reports carries a query string. + await page.route( + (url) => url.pathname === "/reports" && url.search === "", + async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + headers: { ...response.headers(), "content-security-policy": csp }, + }); + }, + ); + + const violations: string[] = []; + page.on("console", (message) => { + if (message.text().includes("Content Security Policy")) + violations.push(message.text()); + }); + + await page.goto("/reports"); + + // Rendering at all proves the bundle's script and stylesheet loaded, and the + // empty state proves the fetch to /api/admin/v1 survived `connect-src 'self'`. + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + await expect(page.getByText("No records.")).toBeVisible(); + expect(violations).toEqual([]); +}); + +test("the linked favicon loads under the admin csp", async ({ page }) => { + const csp = adminCsp(); + + await page.route( + (url) => url.pathname === "/" && url.search === "", + async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + headers: { ...response.headers(), "content-security-policy": csp }, + }); + }, + ); + + const violations: string[] = []; + page.on("console", (message) => { + if (message.text().includes("Content Security Policy")) + violations.push(message.text()); + }); + + await page.goto("/"); + + const href = await page.locator("link[rel=icon]").getAttribute("href"); + expect(href).toBe("/favicon.svg"); + + // Headless Chromium never issues the `` request itself, so + // load the same file the same way the policy sees it: an image fetch under + // `img-src 'self'`. A blocked fetch rejects `decode()`; a successful one + // proves the icon renders, and that the SVG's own inline