From 461aa45884fc2611ca1fcf96c05886e653e87035 Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sat, 8 Aug 2026 18:27:14 -0400 Subject: [PATCH 01/13] =?UTF-8?q?feat(web-push):=20browser=20push=20notifi?= =?UTF-8?q?cations=20+=20PWA=20=E2=80=94=20the=20web=20app=20as=20a=20firs?= =?UTF-8?q?t-party=20notification=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web app becomes a real, selectable notification route for automations, at parity with Slack/Telegram: a bundled first-party channel extension (web-push) delivers W3C Web Push (RFC 8030/8291/8292) to the user's enrolled browsers through the existing catalog → notification-channel set → notifier → delivery coordinator → channel adapter → policy-enforced egress chain, with zero new routing machinery and the two-lane delivery contract untouched. The WebUI ships as an installable PWA (root-scope service worker with push + notification-click deep links; manifest and icons already existed), and the automations page's notification-channels panel replaces the always-on "web app" placeholder with a real toggleable row plus a per-browser enroll/disable flow. New crates: ironclaw_web_push (domain: subscription records + CAS store, RFC 8291 aes128gcm encryption pinned to the RFC's Appendix A vector, VAPID key-material generation, transport-free request planning, channel identity grammar, late-bound runtime slot) and ironclaw_web_push_extension (channel package: manifest with vapid_authorization egress injection, adapter with 404/410 pruning and honest Sent-without-ref evidence, personal-DM codec, owner-scoped catalog provider). One generic host addition: the RuntimeCredentialTarget::VapidAuthorization egress injection kind — the host signs the RFC 8292 ES256 JWT at the existing credential chokepoint with the audience derived from the request's own push-service origin; adapters never see key bytes. VAPID material is auto-generated and seeded at composition boot. Enrollment is an authenticated product surface (three new /api/webchat/v2/web-push routes) with descriptors declared in ironclaw_product_contracts::web_push per the transport/product boundary, and endpoints validate against the manifest-declared push-service hosts. Also fixes a boot bug the new integration tests exposed: DeploymentChannelBinding rejected outbound-only channels, which would have failed the runtime build at serve. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 39 ++ Cargo.toml | 4 +- FEATURE_PARITY.md | 2 +- crates/AGENTS.md | 19 +- .../tests/reborn_dependency_boundaries.rs | 30 +- .../tests/reborn_extension_specificity.rs | 13 +- .../tests/reborn_restructure_baselines.rs | 2 +- .../tests/reborn_same_layer_edge_inventory.rs | 14 +- crates/app/ironclaw_cli/AGENTS.md | 2 +- crates/app/ironclaw_cli/Cargo.toml | 2 + .../ironclaw_cli/src/first_party/bundles.rs | 23 +- crates/app/ironclaw_cli/src/runtime/mod.rs | 22 +- .../src/runtime/native_extensions.rs | 38 +- crates/app/ironclaw_composition/Cargo.toml | 1 + .../app/ironclaw_composition/src/factory.rs | 17 + .../factory/production_backend_assembly.rs | 160 ++++++- .../src/factory/production_build_assembly.rs | 6 + crates/app/ironclaw_composition/src/input.rs | 36 ++ crates/app/ironclaw_composition/src/lib.rs | 1 + .../src/product_surface.rs | 9 + .../app/ironclaw_composition/src/runtime.rs | 2 + .../src/runtime/tests/core.rs | 1 + .../tests/trigger_poller_e2e.rs | 1 + .../src/channel.rs | 4 + .../contracts/ironclaw_host_api/src/http.rs | 33 ++ .../ironclaw_product_contracts/AGENTS.md | 1 + .../ironclaw_product_contracts/src/lib.rs | 1 + .../src/product_wire.rs | 66 +++ .../src/web_push.rs | 38 ++ crates/domains/AGENTS.md | 5 +- crates/domains/ironclaw_web_push/Cargo.toml | 28 ++ crates/domains/ironclaw_web_push/README.md | 39 ++ .../domains/ironclaw_web_push/src/crypto.rs | 365 ++++++++++++++++ crates/domains/ironclaw_web_push/src/error.rs | 52 +++ .../domains/ironclaw_web_push/src/grammar.rs | 99 +++++ crates/domains/ironclaw_web_push/src/lib.rs | 43 ++ .../domains/ironclaw_web_push/src/message.rs | 219 ++++++++++ .../domains/ironclaw_web_push/src/runtime.rs | 113 +++++ crates/domains/ironclaw_web_push/src/store.rs | 396 ++++++++++++++++++ .../ironclaw_web_push/src/subscription.rs | 355 ++++++++++++++++ crates/domains/ironclaw_web_push/src/vapid.rs | 127 ++++++ crates/extensions/AGENTS.md | 3 +- .../src/deployment_channels.rs | 32 +- .../src/test_support.rs | 26 ++ .../extensions/packages/web-push/Cargo.toml | 27 ++ crates/extensions/packages/web-push/README.md | 31 ++ .../packages/web-push/manifest.toml | 78 ++++ .../packages/web-push/src/channel.rs | 289 +++++++++++++ .../extensions/packages/web-push/src/lib.rs | 22 + .../web-push/src/preference_targets.rs | 95 +++++ .../packages/web-push/src/targets.rs | 136 ++++++ .../web-push/tests/manifest_lockstep.rs | 78 ++++ .../kernel/ironclaw_host_runtime/Cargo.toml | 4 + .../src/egress/credential.rs | 18 + .../ironclaw_host_runtime/src/egress/mod.rs | 1 + .../ironclaw_host_runtime/src/egress/vapid.rs | 194 +++++++++ .../src/first_party_tools/schemas.rs | 2 +- .../first_party_tools/trigger_management.rs | 2 +- crates/lanes/ironclaw_sandbox/src/plan.rs | 6 +- crates/product/ironclaw_assistant/AGENTS.md | 1 + crates/product/ironclaw_assistant/Cargo.toml | 1 + crates/product/ironclaw_assistant/src/lib.rs | 15 +- .../ironclaw_assistant/src/reborn_services.rs | 29 +- .../product_capability_handlers.rs | 17 + .../src/reborn_services/web_push.rs | 231 ++++++++++ crates/product/ironclaw_webui/CONTRACT.md | 4 +- .../ironclaw_webui/frontend/public/sw.js | 78 ++++ .../ironclaw_webui/frontend/src/i18n/ar.ts | 12 +- .../ironclaw_webui/frontend/src/i18n/de.ts | 12 +- .../ironclaw_webui/frontend/src/i18n/en.ts | 12 +- .../ironclaw_webui/frontend/src/i18n/es.ts | 12 +- .../ironclaw_webui/frontend/src/i18n/fr.ts | 12 +- .../ironclaw_webui/frontend/src/i18n/hi.ts | 12 +- .../ironclaw_webui/frontend/src/i18n/ja.ts | 12 +- .../ironclaw_webui/frontend/src/i18n/ko.ts | 12 +- .../ironclaw_webui/frontend/src/i18n/pt-BR.ts | 12 +- .../ironclaw_webui/frontend/src/i18n/uk.ts | 12 +- .../ironclaw_webui/frontend/src/i18n/zh-CN.ts | 12 +- .../frontend/src/lib/api.test.ts | 88 ++++ .../ironclaw_webui/frontend/src/lib/api.ts | 33 ++ .../frontend/src/lib/web-push.test.ts | 189 +++++++++ .../frontend/src/lib/web-push.ts | 143 +++++++ .../ironclaw_webui/frontend/src/main.tsx | 5 + .../notification-channels-panel.test.ts | 227 +++++++++- .../notification-channels-panel.tsx | 92 +++- .../automations/hooks/useWebPushDevice.ts | 74 ++++ .../src/webui_v2/descriptors.rs | 54 +++ .../ironclaw_webui/src/webui_v2/handlers.rs | 57 +++ .../ironclaw_webui/src/webui_v2/mod.rs | 6 +- .../ironclaw_webui/src/webui_v2/router.rs | 15 +- .../tests/webui_v2_descriptors_contract.rs | 55 ++- .../2026-08-08-web-push-notifications.md | 173 ++++++++ .../communication-delivery-resolution.md | 10 +- docs/reborn/contracts/triggers.md | 7 +- docs/reborn/extension-runtime/overview.md | 7 +- docs/reborn/target-architecture/PROPOSAL.md | 4 + scripts/ci/composition-budget.toml | 11 +- tests/CLAUDE.md | 3 + tests/integration/delivery_user_journeys.rs | 363 ++++++++++++++++ .../integration/support/group_constructors.rs | 41 ++ tests/integration/support/harness/mod.rs | 14 + tests/integration/support/harness/options.rs | 54 +++ .../support/harness/profiles/extension.rs | 46 ++ tests/integration/webui_v2_product_api.rs | 216 ++++++++++ 104 files changed, 5889 insertions(+), 78 deletions(-) create mode 100644 crates/contracts/ironclaw_product_contracts/src/web_push.rs create mode 100644 crates/domains/ironclaw_web_push/Cargo.toml create mode 100644 crates/domains/ironclaw_web_push/README.md create mode 100644 crates/domains/ironclaw_web_push/src/crypto.rs create mode 100644 crates/domains/ironclaw_web_push/src/error.rs create mode 100644 crates/domains/ironclaw_web_push/src/grammar.rs create mode 100644 crates/domains/ironclaw_web_push/src/lib.rs create mode 100644 crates/domains/ironclaw_web_push/src/message.rs create mode 100644 crates/domains/ironclaw_web_push/src/runtime.rs create mode 100644 crates/domains/ironclaw_web_push/src/store.rs create mode 100644 crates/domains/ironclaw_web_push/src/subscription.rs create mode 100644 crates/domains/ironclaw_web_push/src/vapid.rs create mode 100644 crates/extensions/packages/web-push/Cargo.toml create mode 100644 crates/extensions/packages/web-push/README.md create mode 100644 crates/extensions/packages/web-push/manifest.toml create mode 100644 crates/extensions/packages/web-push/src/channel.rs create mode 100644 crates/extensions/packages/web-push/src/lib.rs create mode 100644 crates/extensions/packages/web-push/src/preference_targets.rs create mode 100644 crates/extensions/packages/web-push/src/targets.rs create mode 100644 crates/extensions/packages/web-push/tests/manifest_lockstep.rs create mode 100644 crates/kernel/ironclaw_host_runtime/src/egress/vapid.rs create mode 100644 crates/product/ironclaw_assistant/src/reborn_services/web_push.rs create mode 100644 crates/product/ironclaw_webui/frontend/public/sw.js create mode 100644 crates/product/ironclaw_webui/frontend/src/lib/web-push.test.ts create mode 100644 crates/product/ironclaw_webui/frontend/src/lib/web-push.ts create mode 100644 crates/product/ironclaw_webui/frontend/src/pages/automations/hooks/useWebPushDevice.ts create mode 100644 docs/internal/design/2026-08-08-web-push-notifications.md diff --git a/Cargo.lock b/Cargo.lock index f760915c602..f2310c801e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3481,6 +3481,8 @@ dependencies = [ "ironclaw_slack_extension", "ironclaw_telegram_extension", "ironclaw_trace_commons", + "ironclaw_web_push", + "ironclaw_web_push_extension", "ironclaw_webui", "libc", "rand 0.10.2", @@ -3593,6 +3595,7 @@ dependencies = [ "ironclaw_trust", "ironclaw_turn_runner", "ironclaw_turns", + "ironclaw_web_push", "libsql", "secrecy", "serde", @@ -3792,6 +3795,7 @@ dependencies = [ "ironclaw_trust", "ironclaw_turn_runner", "ironclaw_turns", + "ironclaw_web_push", "ironclaw_webui", "libc", "libsql", @@ -4211,6 +4215,7 @@ name = "ironclaw_host_runtime" version = "0.1.0" dependencies = [ "async-trait", + "aws-lc-rs", "axum 0.8.9", "base64 0.23.0", "chrono", @@ -4359,6 +4364,8 @@ dependencies = [ "ironclaw_turn_runner", "ironclaw_turns", "ironclaw_wasm", + "ironclaw_web_push", + "ironclaw_web_push_extension", "ironclaw_webui", "libsql", "pretty_assertions", @@ -5142,6 +5149,38 @@ dependencies = [ "wasmtime", ] +[[package]] +name = "ironclaw_web_push" +version = "0.1.0" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64 0.23.0", + "ironclaw_filesystem", + "ironclaw_host_api", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "ironclaw_web_push_extension" +version = "0.1.0" +dependencies = [ + "async-trait", + "ironclaw_extension_contracts", + "ironclaw_host_api", + "ironclaw_outbound", + "ironclaw_web_push", + "serde_json", + "tokio", + "toml 0.9.12+spec-1.1.0", + "tracing", +] + [[package]] name = "ironclaw_webui" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0ad0f9e02b5..645b564950c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/contracts/ironclaw_common", "crates/substrates/ironclaw_observability", "crates/contracts/ironclaw_host_api", "crates/product/ironclaw_host_ingress", "crates/substrates/ironclaw_libsql_runtime", "crates/substrates/ironclaw_filesystem", "crates/domains/ironclaw_attachments", "crates/domains/ironclaw_extractors", "crates/domains/ironclaw_memory", "crates/extensions/packages/memory-native", "crates/extensions/packages/mem0", "crates/events/ironclaw_event_log", "crates/events/ironclaw_event_projections", "crates/events/ironclaw_event_streams", "crates/events/ironclaw_event_store", "crates/extensions/ironclaw_extension_registry", "crates/extensions/ironclaw_extension_host", "crates/extensions/ironclaw_extension_manager", "crates/kernel/ironclaw_processes", "crates/lanes/ironclaw_sandbox", "crates/lanes/ironclaw_mcp", "crates/lanes/ironclaw_wasm", "crates/lanes/ironclaw_wasm_limiter", "crates/kernel/ironclaw_capabilities", "crates/substrates/ironclaw_secrets", "crates/substrates/ironclaw_network", "crates/kernel/ironclaw_host_runtime", "crates/kernel/ironclaw_runtime_policy", "crates/kernel/ironclaw_authorization", "crates/kernel/ironclaw_approvals", "crates/kernel/ironclaw_resources", "crates/domains/ironclaw_auth", "crates/kernel/ironclaw_trust", "crates/kernel/ironclaw_turns", "crates/contracts/ironclaw_loop_contracts", "crates/contracts/ironclaw_extension_contracts", "crates/contracts/ironclaw_product_contracts", "crates/loop/ironclaw_agent_loop", "crates/domains/ironclaw_threads", "crates/contracts/ironclaw_prompt_envelope", "crates/loop/ironclaw_hooks", "crates/loop/ironclaw_loop_host", "crates/loop/ironclaw_turn_runner", "crates/app/ironclaw_config", "crates/product/ironclaw_operator", "crates/app/ironclaw_composition", "crates/domains/ironclaw_identity", "crates/extensions/ironclaw_extension_support", "crates/app/ironclaw_cli", "crates/domains/ironclaw_trace_commons", "crates/product/ironclaw_webui", "crates/product/ironclaw_openai_compat", "crates/domains/ironclaw_conversations", "crates/product/ironclaw_assistant", "crates/extensions/packages/telegram", "crates/extensions/packages/slack", "crates/domains/ironclaw_outbound", "crates/domains/ironclaw_triggers", "crates/app/ironclaw_architecture_tests", "crates/substrates/ironclaw_safety", "crates/domains/ironclaw_skills", "crates/domains/ironclaw_llm", "tools/ironclaw_stress"] +members = [".", "crates/contracts/ironclaw_common", "crates/substrates/ironclaw_observability", "crates/contracts/ironclaw_host_api", "crates/product/ironclaw_host_ingress", "crates/substrates/ironclaw_libsql_runtime", "crates/substrates/ironclaw_filesystem", "crates/domains/ironclaw_attachments", "crates/domains/ironclaw_extractors", "crates/domains/ironclaw_memory", "crates/extensions/packages/memory-native", "crates/extensions/packages/mem0", "crates/events/ironclaw_event_log", "crates/events/ironclaw_event_projections", "crates/events/ironclaw_event_streams", "crates/events/ironclaw_event_store", "crates/extensions/ironclaw_extension_registry", "crates/extensions/ironclaw_extension_host", "crates/extensions/ironclaw_extension_manager", "crates/kernel/ironclaw_processes", "crates/lanes/ironclaw_sandbox", "crates/lanes/ironclaw_mcp", "crates/lanes/ironclaw_wasm", "crates/lanes/ironclaw_wasm_limiter", "crates/kernel/ironclaw_capabilities", "crates/substrates/ironclaw_secrets", "crates/substrates/ironclaw_network", "crates/kernel/ironclaw_host_runtime", "crates/kernel/ironclaw_runtime_policy", "crates/kernel/ironclaw_authorization", "crates/kernel/ironclaw_approvals", "crates/kernel/ironclaw_resources", "crates/domains/ironclaw_auth", "crates/kernel/ironclaw_trust", "crates/kernel/ironclaw_turns", "crates/contracts/ironclaw_loop_contracts", "crates/contracts/ironclaw_extension_contracts", "crates/contracts/ironclaw_product_contracts", "crates/loop/ironclaw_agent_loop", "crates/domains/ironclaw_threads", "crates/contracts/ironclaw_prompt_envelope", "crates/loop/ironclaw_hooks", "crates/loop/ironclaw_loop_host", "crates/loop/ironclaw_turn_runner", "crates/app/ironclaw_config", "crates/product/ironclaw_operator", "crates/app/ironclaw_composition", "crates/domains/ironclaw_identity", "crates/extensions/ironclaw_extension_support", "crates/app/ironclaw_cli", "crates/domains/ironclaw_trace_commons", "crates/product/ironclaw_webui", "crates/product/ironclaw_openai_compat", "crates/domains/ironclaw_conversations", "crates/product/ironclaw_assistant", "crates/extensions/packages/telegram", "crates/extensions/packages/slack", "crates/extensions/packages/web-push", "crates/domains/ironclaw_outbound", "crates/domains/ironclaw_triggers", "crates/domains/ironclaw_web_push", "crates/app/ironclaw_architecture_tests", "crates/substrates/ironclaw_safety", "crates/domains/ironclaw_skills", "crates/domains/ironclaw_llm", "tools/ironclaw_stress"] default-members = ["crates/app/ironclaw_cli"] exclude = [ # Standalone helper binary, `[workspace]`-rooted and never built here (it @@ -198,6 +198,8 @@ ironclaw_webui = { path = "crates/product/ironclaw_webui", version = "0.1.0" } # constants; Slack pairing/actor-resolution int-tier tests need them directly. ironclaw_slack_extension = { path = "crates/extensions/packages/slack", version = "0.1.0" } ironclaw_telegram_extension = { path = "crates/extensions/packages/telegram", version = "0.1.0" } +ironclaw_web_push = { path = "crates/domains/ironclaw_web_push", version = "0.1.0" } +ironclaw_web_push_extension = { path = "crates/extensions/packages/web-push", version = "0.1.0" } ironclaw_config = { path = "crates/app/ironclaw_config", version = "0.1.0" } ironclaw_openai_compat = { path = "crates/product/ironclaw_openai_compat", version = "0.1.0"} ironclaw_threads = { path = "crates/domains/ironclaw_threads", version = "0.1.0" } diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index e36d3294fb5..96d183427fa 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -654,7 +654,7 @@ Trace Commons issuer/TenantCtx note: the server-side `zmanian/tracedao-server` s | Control UI i18n | ✅ | ❌ | P3 | English, Chinese, Portuguese; expanded with Persian (fa), Dutch (nl), Vietnamese (vi), Italian (it), Arabic (ar), Thai (th), Traditional Chinese (zh-TW) | | WebChat theme sync | ✅ | ❌ | P3 | Sync with system dark/light mode | | Partial output on abort | ✅ | ❌ | P2 | Preserve partial output when aborting | -| PWA + Web Push | ✅ | ❌ | P3 | PWA install + Web Push notifications for Gateway chat | +| PWA + Web Push | ✅ | ✅ | P3 | PWA install (root-scope service worker) + Web Push notifications: the `web-push` channel delivers RFC 8030/8291/8292 browser pushes for automation notices and model-directed deliveries | | Talk Mode (browser realtime voice) | ✅ | ❌ | P3 | OpenAI Realtime + Google Live WebSocket; Gateway-minted ephemeral secrets; backend realtime relay | | Steer queued messages | ✅ | ❌ | P3 | Steer action on queued messages injects follow-up into active run without retyping | | Quick Settings dashboard | ✅ | ❌ | P3 | Refreshed grid + presets + quick-create flows + assistant avatar overrides | diff --git a/crates/AGENTS.md b/crates/AGENTS.md index 1d6a8c21c58..c43449c0c81 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -35,7 +35,7 @@ Do not eagerly load every crate guide. Route, then read. | [`contracts/`](./contracts/AGENTS.md) | Neutral vocabulary and ports — the leaf tier; nothing executes, persists, or names a vendor. | You are adding or changing a shared type, identity, port trait, or DTO that more than one tier must see. | | [`substrates/`](./substrates/AGENTS.md) | Privileged mechanisms the kernel mediates: filesystem, libSQL admission, secrets, network, safety scanning, observability macros. | You are changing storage/network/secret/scanning *mechanism*, not who may use it. | | [`events/`](./events/AGENTS.md) | What already happened: redacted evidence vocabulary, durable stores, replay-derived projections, admission-checked streams. | You are changing event vocabulary, event persistence, a read model, or stream delivery. | -| [`domains/`](./domains/AGENTS.md) | Typed record/service owners behind the kernel: threads, conversations, triggers, memory, skills, auth, attachments, extractors, identity, llm, trace_commons, outbound. | You are changing a domain's record grammar, service contract, or invariants. | +| [`domains/`](./domains/AGENTS.md) | Typed record/service owners behind the kernel: threads, conversations, triggers, memory, skills, auth, attachments, extractors, identity, llm, trace_commons, outbound, web_push. | You are changing a domain's record grammar, service contract, or invariants. | | [`kernel/`](./kernel/AGENTS.md) | The authority perimeter, one crate per mediation stage: trust → authorization → approvals → resources → runtime_policy → capabilities → processes → turns → host_runtime. | You are changing what is *allowed to happen* or how recovery stays safe. | | [`lanes/`](./lanes/AGENTS.md) | Execution for already-authorized work: wasm, wasm_limiter, mcp, sandbox. | You are changing how an approved invocation physically runs. | | [`loop/`](./loop/AGENTS.md) | Replaceable agent behavior and its hosting: agent_loop, loop_host, turn_runner, hooks. | You are changing what the agent decides next, or the drivers/port adapters that host it. | @@ -62,11 +62,11 @@ its own layer or below (dev-dependencies are outside the matrix): | Layer (low → high) | May depend on | Crates today | | --- | --- | --- | | `contracts` | contracts | 6 | -| `substrates` | contracts, substrates | 27 | +| `substrates` | contracts, substrates | 28 | | `runtimes` | + runtimes | 5 | | `kernel` | + kernel | 9 | | `loops` | + loops | 5 | -| `products` | + products | 7 | +| `products` | + products | 8 | | `app` | + app (everything) | 5 | The standing-exception list (`LAYER_MATRIX_EXCEPTIONS`, same file) is @@ -81,7 +81,7 @@ two do not always rhyme: - `lanes/` crates are `runtimes`-layer; `loop/` crates are `loops`-layer. - `extensions/` is deliberately *vertical*: registry = substrates, support = runtimes, host = loops, manager = products; under `packages/`, the channel - adapter crates (slack, telegram) are products and the memory provider + adapter crates (slack, telegram, web-push) are products and the memory provider crates (memory-native, mem0) are substrates. - Two placement surprises: `product/ironclaw_host_ingress` and `app/ironclaw_config` are `substrates`-layer. @@ -92,7 +92,7 @@ files carry their members' exact layers. ## Workspace facts -**64 packages**: 62 under `crates/`, plus the root package +**66 packages**: 64 under `crates/`, plus the root package `ironclaw_integration_tests` (the in-process Reborn integration suite, `tests/integration/`) and `tools/ironclaw_stress`. One documented exclusion: `tools/ironclaw_silk_decoder`, a standalone helper that is @@ -100,11 +100,12 @@ workspace-`exclude`d. Zero crates sit flat under `crates/` and zero owned placement exceptions remain. The gate is `python3 scripts/ci/check-target-tree.py`, which compares the workspace against the documented tree (PROPOSAL §5); on 2026-08-05 it reports: -`target tree: OK (64 workspace members against 64 documented packages, 1 -documented exclusion(s), 0 owned exception(s))`. +`target tree: OK (66 workspace members against 66 documented packages, 1 +documented exclusion(s), 0 owned exception(s))` (re-derived 2026-08-08 with the +web-push channel's two crates). -Under `crates/extensions/packages/`, 14 package directories: 4 are workspace -crates (`slack`, `telegram`, `memory-native`, `mem0`) and 10 are data-only +Under `crates/extensions/packages/`, 15 package directories: 5 are workspace +crates (`slack`, `telegram`, `web-push`, `memory-native`, `mem0`) and 10 are data-only (manifest + prompts/schemas, some with prebuilt WASM): github, gmail, the five google-*, nearai-mcp, notion-mcp, web-access. Every package directory — data-only ones included — carries its own `README.md`, so the read order diff --git a/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs b/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs index 5b5924c7145..fba1140f737 100644 --- a/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs +++ b/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs @@ -621,7 +621,10 @@ fn reborn_contracts_crates_carry_a_checked_size_ceiling() { // `ActivePreferenceTargetCodecs` port beside its sibling // `PreferenceTargetCodec` — a trait plus a test-shape blanket impl, // no logic. Count read from this test's own failure message. - ("ironclaw_extension_contracts", 7_748), + // 7_748 -> 7_752 (2026-08-08, web-push channel): +4 lines for the + // `VapidAuthorization` arm in the channel egress injection validator + // — schema vocabulary only; signing lives in ironclaw_host_runtime. + ("ironclaw_extension_contracts", 7_752), // Raised 17_501 -> 18_570 by #6831 (standardized messaging framework): // the growth is the `messaging` vocabulary — the StandardMessagingOp // enum, the 12-code error taxonomy, compiled-in canonical schema/prompt @@ -636,7 +639,12 @@ fn reborn_contracts_crates_carry_a_checked_size_ceiling() { // sandbox transport now exposes graceful lifecycle release. This is // contract vocabulary; execution and provider cleanup remain in the // sandbox runtime lane. - ("ironclaw_host_api", 18_799), + // Raised 18_799 -> 18_832 by the web-push channel: the + // `RuntimeCredentialTarget::VapidAuthorization` injection kind and the + // `VapidCredentialMaterialV1` material schema (RFC 8292 vocabulary, + // declarations only); ES256 signing stays at the host egress + // credential chokepoint in ironclaw_host_runtime. + ("ironclaw_host_api", 18_832), // 14_479 -> 13_949 (2026-08-07, #7157): downward re-capture after the // delivery-heuristic vocabulary (stored trigger delivery targets and // their run-profile plumbing) left this crate with the two-lane @@ -669,7 +677,15 @@ fn reborn_contracts_crates_carry_a_checked_size_ceiling() { // growth is the three admin scrape request DTOs and the wire // `RebornListThreadsResponse` reuse — declarations only; authorization, // audit, and artifact building stay in ironclaw_assistant. - ("ironclaw_product_contracts", 15_800), + // Raised 15_800 -> 15_840 by the web-push channel: the browser + // enrollment wire DTO family (`RebornWebPush*` status/subscribe/ + // unsubscribe shapes) — declarations only; validation and storage stay + // in ironclaw_web_push and ironclaw_assistant. + // 15_840 -> 15_879: the web_push descriptor module (the status view + + // subscribe/unsubscribe command descriptors) joined per the + // transport/product boundary — new feature descriptors are declared + // here, not added to the frozen webui→assistant residue. + ("ironclaw_product_contracts", 15_879), ("ironclaw_prompt_envelope", 832), ]; @@ -1435,8 +1451,14 @@ fn reborn_cli_binary_crate_stays_separate_from_v1_root() { "ironclaw_webui", "ironclaw_slack_extension", "ironclaw_telegram_extension", + // The web-push channel package (adapter/codec/target provider) and + // its domain crate: the binary constructs the adapter around the + // late-bound `WebPushRuntimeSlot` (a domain type) and hands the + // slot to composition, which installs storage at assembly. + "ironclaw_web_push", + "ironclaw_web_push_extension", ], - "ironclaw should enter Reborn through ironclaw_composition (assembled runtime), ironclaw_operator (operator/admin control-plane), ironclaw_host_api (neutral provider DTO contracts), ironclaw_extension_contracts (the extension tier's half of those neutral contracts, since WS1.3), ironclaw_product_contracts (the product tier's half, since WS1.4), ironclaw_config (boot-config contract), ironclaw_trace_commons (contributor-side TraceCommons client extracted from the legacy monolith), ironclaw_auth (auth-owned contracts used by binary-assembled first-party credential wiring), and ironclaw_webui (host-owned WebUI serve lifecycle) — plus ironclaw_extension_host (the NativeExtensionFactory contract), ironclaw_extension_manager (the extension/ironhub command surface, since WS2.4) and concrete extension crates for the binary-assembled native factory registry (DEL-7: only the binary and tests may link concrete extension crates). Adding any other workspace crate here re-opens speculative public API access to internal Reborn types.", + "ironclaw should enter Reborn through ironclaw_composition (assembled runtime), ironclaw_operator (operator/admin control-plane), ironclaw_host_api (neutral provider DTO contracts), ironclaw_extension_contracts (the extension tier's half of those neutral contracts, since WS1.3), ironclaw_product_contracts (the product tier's half, since WS1.4), ironclaw_config (boot-config contract), ironclaw_trace_commons (contributor-side TraceCommons client extracted from the legacy monolith), ironclaw_auth (auth-owned contracts used by binary-assembled first-party credential wiring), and ironclaw_webui (host-owned WebUI serve lifecycle) — plus ironclaw_extension_host (the NativeExtensionFactory contract), ironclaw_extension_manager (the extension/ironhub command surface, since WS2.4), concrete extension crates for the binary-assembled native factory registry (DEL-7: only the binary and tests may link concrete extension crates), and ironclaw_web_push (the domain type behind the web-push binding's late-bound runtime slot). Adding any other workspace crate here re-opens speculative public API access to internal Reborn types.", ); assert_workspace_deps_exactly( &dependencies_all_kinds, diff --git a/crates/app/ironclaw_architecture_tests/tests/reborn_extension_specificity.rs b/crates/app/ironclaw_architecture_tests/tests/reborn_extension_specificity.rs index e82c5b13233..e92714726ed 100644 --- a/crates/app/ironclaw_architecture_tests/tests/reborn_extension_specificity.rs +++ b/crates/app/ironclaw_architecture_tests/tests/reborn_extension_specificity.rs @@ -116,7 +116,18 @@ fn resolve_listed_path(root: &Path, logical: &str) -> String { /// Excluding these two restores exactly the pre-move term set: before WS2 the /// inventory was the twelve extension packages plus the fixtures, and it still /// is. -const NON_VENDOR_PROVIDER_PACKAGE_DIRS: &[&str] = &["memory-native", "mem0"]; +/// +/// `web-push` joined 2026-08-08 with the browser-notification channel: the id +/// is the IETF protocol name (RFC 8030/8291/8292), not a vendor, and the +/// package is first-party deployment infrastructure for the product's own web +/// surface. Its protocol mechanics live in the `ironclaw_web_push` domain +/// crate the same way the provider-neutral memory contract lives in +/// `ironclaw_memory`, so composition wiring, the product wire DTOs, and the +/// domain crate legitimately name it. Its manifest's egress hosts (the push +/// services browsers mint endpoints on) are likewise protocol infrastructure, +/// not vendor vocabulary — and generic code does not hardcode them anyway: +/// the enrollment allowlist is read from the resolved manifest at composition. +const NON_VENDOR_PROVIDER_PACKAGE_DIRS: &[&str] = &["memory-native", "mem0", "web-push"]; /// Directories whose `*/manifest.toml` files form the package inventory the /// forbidden vocabulary derives from. diff --git a/crates/app/ironclaw_architecture_tests/tests/reborn_restructure_baselines.rs b/crates/app/ironclaw_architecture_tests/tests/reborn_restructure_baselines.rs index 437f15cde99..652635adc64 100644 --- a/crates/app/ironclaw_architecture_tests/tests/reborn_restructure_baselines.rs +++ b/crates/app/ironclaw_architecture_tests/tests/reborn_restructure_baselines.rs @@ -125,7 +125,7 @@ const WS0_COMPOSITION_SHARE_BP: usize = 658; /// observed value move with this record so the increase is explicit. /// ✎ Union re-measured 40_432 → 40_747 on 2026-08-07 after merging #7157's /// delivery refactor with #7214's sandbox profile and binding assembly. -const COMPOSITION_ABSOLUTE_SRC_LOC: usize = 40_747; +const COMPOSITION_ABSOLUTE_SRC_LOC: usize = 41_035; /// Composition dispatch, from the same `--print` run: "composition dispatch: /// 827 Arc (governed prod, excl slack/extension_host)". diff --git a/crates/app/ironclaw_architecture_tests/tests/reborn_same_layer_edge_inventory.rs b/crates/app/ironclaw_architecture_tests/tests/reborn_same_layer_edge_inventory.rs index 6c90d84e270..d70db4d82fd 100644 --- a/crates/app/ironclaw_architecture_tests/tests/reborn_same_layer_edge_inventory.rs +++ b/crates/app/ironclaw_architecture_tests/tests/reborn_same_layer_edge_inventory.rs @@ -415,6 +415,16 @@ const SAME_LAYER_EDGE_INVENTORY: &[SameLayerEdge] = &[ owner: "domains/", decided_in: "WS6", }, + SameLayerEdge { + // The web-push subscription store rides the scoped-filesystem plane + // (per database.md), the same edge every persisting domain crate + // carries; both sit at the substrates layer by family rule. + crate_name: "ironclaw_web_push", + dependency_name: "ironclaw_filesystem", + layer: "substrates", + owner: "domains/", + decided_in: "web-push channel (2026-08-08)", + }, SameLayerEdge { crate_name: "ironclaw_attachments", dependency_name: "ironclaw_threads", @@ -718,7 +728,7 @@ const SAME_LAYER_EDGE_INVENTORY: &[SameLayerEdge] = &[ /// `ironclaw_first_party_extension_ports` into `ironclaw_loop_host` removed the /// `loops` pair the two of them formed. No edge was re-plumbed and none was /// added: the crate's five workspace dependencies were already `loop_host`'s. -const SAME_LAYER_EDGE_BASELINE: usize = 70; +const SAME_LAYER_EDGE_BASELINE: usize = 71; /// Sanity floors for the metadata walk. A gate that scans nothing must never /// read as success; these are deliberately far below the live values (✎ **65** @@ -813,6 +823,8 @@ const CRATE_LAYER_ORIGINS: &[(&str, &str)] = &[ ("ironclaw_telegram_extension", "products"), ("ironclaw_threads", "substrates"), ("ironclaw_triggers", "substrates"), + ("ironclaw_web_push", "substrates"), + ("ironclaw_web_push_extension", "products"), ("ironclaw_trust", "kernel"), ("ironclaw_turns", "kernel"), ("ironclaw_wasm", "runtimes"), diff --git a/crates/app/ironclaw_cli/AGENTS.md b/crates/app/ironclaw_cli/AGENTS.md index 1403a985056..0790ffbae7f 100644 --- a/crates/app/ironclaw_cli/AGENTS.md +++ b/crates/app/ironclaw_cli/AGENTS.md @@ -15,7 +15,7 @@ This crate owns the standalone `ironclaw` command surface. Keep it small, explic - Keep commands side-effect free unless the command name and issue explicitly require mutation. - Use `IRONCLAW_REBORN_HOME` / `~/.ironclaw/reborn`; do not write current v1 state. - no v1 runtime imports. (The `ironclaw_legacy` root package, its `src/` tree, and `ironclaw_engine` have all been deleted, so this is now unenforceable-by-construction rather than a live hazard.) -- Do not add workspace dependencies beyond the current `[dependencies]` set without an architecture test update and explicit PR rationale. That set is `ironclaw_composition`, `ironclaw_config`, `ironclaw_trace_commons`, `ironclaw_webui` (host-owned WebUI serve lifecycle), `ironclaw_operator` (operator/admin implementation), `ironclaw_host_api` (neutral provider DTO contracts), `ironclaw_auth` (auth-owned contracts used by binary-assembled first-party wiring), `ironclaw_product_contracts` and `ironclaw_extension_contracts` (neutral product/extension DTO contracts), and the binary-supplied extension packages `ironclaw_extension_host`, `ironclaw_extension_manager` (the extension-management product face split out of the host in WS2.4 — the `extension` and `ironhub` commands render it), `ironclaw_extension_support`, `ironclaw_slack_extension`, `ironclaw_telegram_extension` (the binary is the only place concrete extensions may be linked). Re-derive with `grep -n '^ironclaw' crates/app/ironclaw_cli/Cargo.toml`. Provider registry/model UX should enter through the operator/admin facade, not a separate CLI-only path; product-auth workflow should enter through auth-owned contracts rather than composition-owned facades. +- Do not add workspace dependencies beyond the current `[dependencies]` set without an architecture test update and explicit PR rationale. That set is `ironclaw_composition`, `ironclaw_config`, `ironclaw_trace_commons`, `ironclaw_webui` (host-owned WebUI serve lifecycle), `ironclaw_operator` (operator/admin implementation), `ironclaw_host_api` (neutral provider DTO contracts), `ironclaw_auth` (auth-owned contracts used by binary-assembled first-party wiring), `ironclaw_product_contracts` and `ironclaw_extension_contracts` (neutral product/extension DTO contracts), and the binary-supplied extension packages `ironclaw_extension_host`, `ironclaw_extension_manager` (the extension-management product face split out of the host in WS2.4 — the `extension` and `ironhub` commands render it), `ironclaw_extension_support`, `ironclaw_slack_extension`, `ironclaw_telegram_extension`, `ironclaw_web_push_extension` plus its domain crate `ironclaw_web_push` (the binary constructs the web-push adapter around the late-bound runtime slot composition later fills; the binary is the only place concrete extensions may be linked). Re-derive with `grep -n '^ironclaw' crates/app/ironclaw_cli/Cargo.toml`. Provider registry/model UX should enter through the operator/admin facade, not a separate CLI-only path; product-auth workflow should enter through auth-owned contracts rather than composition-owned facades. ## Adding a command diff --git a/crates/app/ironclaw_cli/Cargo.toml b/crates/app/ironclaw_cli/Cargo.toml index 16da3bb8178..1279014694f 100644 --- a/crates/app/ironclaw_cli/Cargo.toml +++ b/crates/app/ironclaw_cli/Cargo.toml @@ -71,6 +71,8 @@ ironclaw_extension_support = { path = "../../extensions/ironclaw_extension_suppo ironclaw_auth = { path = "../../domains/ironclaw_auth", version = "0.1.0" } ironclaw_slack_extension = { path = "../../extensions/packages/slack", version = "0.1.0" } ironclaw_telegram_extension = { path = "../../extensions/packages/telegram", version = "0.1.0" } +ironclaw_web_push = { path = "../../domains/ironclaw_web_push", version = "0.1.0" } +ironclaw_web_push_extension = { path = "../../extensions/packages/web-push", version = "0.1.0" } ironclaw_webui = { path = "../../product/ironclaw_webui", version = "0.1.0" } rand = "0.10" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls-native-roots", "stream"] } diff --git a/crates/app/ironclaw_cli/src/first_party/bundles.rs b/crates/app/ironclaw_cli/src/first_party/bundles.rs index 537b3705e42..414805fbf19 100644 --- a/crates/app/ironclaw_cli/src/first_party/bundles.rs +++ b/crates/app/ironclaw_cli/src/first_party/bundles.rs @@ -24,7 +24,7 @@ const GSUITE_SEARCH_ALIASES: &[&str] = &[ /// input. Every bundled package is converted; the real inventory must be /// injected here or first-party extensions silently vanish from the catalog. pub(crate) fn bundled_first_party_bundles() -> Vec { - bundled_packages() + let mut bundles: Vec = bundled_packages() .into_iter() .map(|bundle| { let assets = bundle @@ -69,5 +69,24 @@ pub(crate) fn bundled_first_party_bundles() -> Vec { search_aliases, } }) - .collect() + .collect(); + // The web-push channel package carries a crate (adapter/codec/provider), + // so its manifest is embedded crate-locally and bundled here — the binary + // is the one place that links concrete package crates, and adding it to + // `ironclaw_extension_support::packages` would grow the §11.2.7 + // cross-crate include inventory instead. + bundles.push(FirstPartyPackageBundle { + id: ironclaw_web_push::WEB_PUSH_EXTENSION_ID.to_string(), + display_name: "Browser notifications".to_string(), + manifest_toml: ironclaw_web_push_extension::MANIFEST.to_string(), + assets: vec![FirstPartyPackageAsset { + path: "manifest.toml".to_string(), + bytes: ironclaw_web_push_extension::MANIFEST.as_bytes().to_vec(), + }], + onboarding: None, + oauth_setup: None, + trust_effects: None, + search_aliases: Vec::new(), + }); + bundles } diff --git a/crates/app/ironclaw_cli/src/runtime/mod.rs b/crates/app/ironclaw_cli/src/runtime/mod.rs index 80be95d9b04..4ab76cd4f1a 100644 --- a/crates/app/ironclaw_cli/src/runtime/mod.rs +++ b/crates/app/ironclaw_cli/src/runtime/mod.rs @@ -659,9 +659,15 @@ fn with_binary_host_extension_bindings_from_bundles( first_party_bundles: Vec, ) -> anyhow::Result { crate::first_party::assert_first_party_bundles_present(&first_party_bundles)?; + let channel_extensions = native_extensions::bundled_channel_extensions(); + let mut services_input = services_input + .with_channel_extension_bindings(channel_extensions.bindings) + .with_web_push_runtime_slot(channel_extensions.web_push_runtime); + if let Some(subject) = web_push_vapid_subject_from_env() { + services_input = services_input.with_web_push_vapid_subject(subject); + } Ok(services_input .with_native_extension_factories(native_extensions::bundled_native_extension_factories()) - .with_channel_extension_bindings(native_extensions::bundled_channel_extension_bindings()) .with_first_party_bundles(first_party_bundles) .with_first_party_registrars(crate::first_party::bundled_first_party_registrars()) .with_credential_account_visibility_policy( @@ -669,6 +675,20 @@ fn with_binary_host_extension_bindings_from_bundles( )) } +/// RFC 8292 `sub` for the seeded VAPID credential: the deployment's public +/// https base URL when one is configured, else `None` (composition falls +/// back to a stable placeholder). Reads the same env var the serve command +/// validates for OAuth callbacks; a malformed value degrades to the +/// placeholder rather than failing boot. +fn web_push_vapid_subject_from_env() -> Option { + let raw = std::env::var("IRONCLAW_REBORN_WEBUI_BASE_URL").ok()?; // silent-ok: optional env-derived contact URI, placeholder fallback is safe + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.starts_with("https://") && trimmed.len() > "https://".len() { + return Some(trimmed.to_string()); + } + None +} + pub(crate) struct RuntimeServicesInput { pub(crate) services_input: RebornHostBindings, pub(crate) profile: RebornProfile, diff --git a/crates/app/ironclaw_cli/src/runtime/native_extensions.rs b/crates/app/ironclaw_cli/src/runtime/native_extensions.rs index 753d0a410fc..1563b4401f1 100644 --- a/crates/app/ironclaw_cli/src/runtime/native_extensions.rs +++ b/crates/app/ironclaw_cli/src/runtime/native_extensions.rs @@ -20,25 +20,57 @@ pub(crate) fn bundled_native_extension_factories() -> Vec, + pub(crate) web_push_runtime: ironclaw_web_push::WebPushRuntimeSlot, +} + /// Deployment channel-adapter bindings. These are independent of native tool /// loading: the host mounts manifest-declared ingress before any user /// installation exists, so every deployment channel adapter is linked here. /// Composition never names a concrete extension crate. -pub(crate) fn bundled_channel_extension_bindings() -> Vec { - vec![ +pub(crate) fn bundled_channel_extensions() -> BundledChannelExtensions { + let web_push_runtime = ironclaw_web_push::WebPushRuntimeSlot::new(); + let bindings = vec![ ChannelExtensionBinding { extension_id: ExtensionId::from_trusted("slack".to_string()), adapter: Arc::new(ironclaw_slack_extension::SlackChannelAdapter), preference_target_codec: Some(Arc::new( ironclaw_slack_extension::SlackPreferenceTargetCodec, )), + outbound_target_provider: None, }, ChannelExtensionBinding { extension_id: ExtensionId::from_trusted("telegram".to_string()), adapter: Arc::new(TelegramChannelAdapter::default()), preference_target_codec: Some(Arc::new(TelegramPreferenceTargetCodec)), + outbound_target_provider: None, + }, + ChannelExtensionBinding { + extension_id: ExtensionId::from_trusted("web-push".to_string()), + adapter: Arc::new(ironclaw_web_push_extension::WebPushChannelAdapter::new( + web_push_runtime.clone(), + )), + preference_target_codec: Some(Arc::new( + ironclaw_web_push_extension::WebPushPreferenceTargetCodec, + )), + outbound_target_provider: Some(Arc::new( + ironclaw_web_push_extension::WebPushOutboundTargetProvider::new(), + )), }, - ] + ]; + BundledChannelExtensions { + bindings, + web_push_runtime, + } +} + +/// Bindings-only view (tests and callers that do not wire composition). +#[cfg(test)] +pub(crate) fn bundled_channel_extension_bindings() -> Vec { + bundled_channel_extensions().bindings } /// `runtime.service = "telegram.extension/v1"` — the Telegram channel diff --git a/crates/app/ironclaw_composition/Cargo.toml b/crates/app/ironclaw_composition/Cargo.toml index f27d5a1ea18..806178be1f3 100644 --- a/crates/app/ironclaw_composition/Cargo.toml +++ b/crates/app/ironclaw_composition/Cargo.toml @@ -80,6 +80,7 @@ ironclaw_network = { path = "../../substrates/ironclaw_network" } ironclaw_observability = { path = "../../substrates/ironclaw_observability" } ironclaw_operator = { path = "../../product/ironclaw_operator" } ironclaw_outbound = { path = "../../domains/ironclaw_outbound" } +ironclaw_web_push = { path = "../../domains/ironclaw_web_push" } ironclaw_processes = { path = "../../kernel/ironclaw_processes" } ironclaw_assistant = { path = "../../product/ironclaw_assistant" } ironclaw_turn_runner = { path = "../../loop/ironclaw_turn_runner" } diff --git a/crates/app/ironclaw_composition/src/factory.rs b/crates/app/ironclaw_composition/src/factory.rs index dc1b18edd95..c1b9960ab56 100644 --- a/crates/app/ironclaw_composition/src/factory.rs +++ b/crates/app/ironclaw_composition/src/factory.rs @@ -258,6 +258,19 @@ pub(crate) type ComposedToolPermissionOverrideStore = pub(crate) type ComposedAutoApproveSettingStore = AutoApproveSettingStore; +/// Composed web-push handles the product surface consumes: the subscription +/// store behind the subscribe/unsubscribe commands and the (non-secret) +/// VAPID public key browsers use as `applicationServerKey`. +#[derive(Clone)] +pub(crate) struct WebPushComposition { + pub(crate) subscriptions: Arc, + pub(crate) vapid_public_key: String, + /// Push-service hosts enrollments may target, read from the web-push + /// manifest's `[[channel.egress]]` declarations (the same list the + /// channel's restricted egress enforces at send time). + pub(crate) allowed_push_hosts: Vec, +} + pub(crate) struct RebornRuntimeStores { pub(crate) host_runtime: Arc, pub(crate) user_sandbox_process_port: @@ -385,6 +398,10 @@ pub(crate) struct RebornRuntimeStores { /// are consumed by `build_reborn_runtime` when the channel host assembly /// starts. pub(crate) channel_extension_bindings: Vec, + /// The web-push channel's composed handles (subscription store + the + /// advertised VAPID public key); `None` when the binary supplied no + /// web-push runtime slot. + pub(crate) web_push: Option, /// Manifest-declared deployment channel surfaces, independent of user /// installation/activation state. pub(crate) deployment_channels: Arc, diff --git a/crates/app/ironclaw_composition/src/factory/production_backend_assembly.rs b/crates/app/ironclaw_composition/src/factory/production_backend_assembly.rs index 88a36e762c0..936c098636a 100644 --- a/crates/app/ironclaw_composition/src/factory/production_backend_assembly.rs +++ b/crates/app/ironclaw_composition/src/factory/production_backend_assembly.rs @@ -339,6 +339,8 @@ pub(super) async fn build_backend_production( nearai_mcp_bootstrap_config, native_extension_factories, channel_extension_bindings, + web_push_runtime_slot, + web_push_vapid_subject, first_party_bundles, first_party_registrars, credential_account_visibility_policy, @@ -476,6 +478,24 @@ pub(super) async fn build_backend_production( let outbound_stores = build_outbound_stores(Arc::clone(&stores.filesystem)); let outbound_delivery_targets = Arc::new(crate::outbound::MutableOutboundDeliveryTargetRegistry::default()); + // Extension-owned catalog providers arrive opaquely on the channel + // bindings (e.g. web-push's constant per-user entry); register each under + // its extension id so composition never names a concrete extension. + for binding in &channel_extension_bindings { + if let Some(provider) = &binding.outbound_target_provider { + outbound_delivery_targets + .register_provider( + binding.extension_id.as_str().to_string(), + Arc::clone(provider), + ) + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!( + "outbound target provider registration failed for {}: {error}", + binding.extension_id + ), + })?; + } + } let skill_auto_activate_learned = Arc::new(AtomicBool::new(true)); let process_backend = production_wiring.runtime_policy.process_backend; let extension_registry = @@ -868,10 +888,13 @@ pub(super) async fn build_backend_production( let deployment_bindings = available_manifests .iter() .filter(|manifest| { - manifest - .channel - .as_ref() - .is_some_and(|channel| channel.inbound && channel.ingress.is_some()) + manifest.channel.as_ref().is_some_and(|channel| { + // Ingress-bearing channels need deployment mounting before any + // installation exists; outbound-only channels (web push) need + // the same deployment binding so delivery resolution finds + // their adapter without an installation record. + (channel.inbound && channel.ingress.is_some()) || channel.outbound + }) }) .filter_map(|manifest| { channel_extension_bindings @@ -996,6 +1019,15 @@ pub(super) async fn build_backend_production( ); extension_management.attach_channel_config(&admin_configuration_resolver); admin_configuration_credential_slot.fill(Arc::clone(&admin_configuration_resolver)); + let web_push = assemble_web_push( + web_push_runtime_slot.as_ref(), + web_push_vapid_subject.as_deref(), + &stores.filesystem, + &secret_store, + &channel_egress_scope, + &deployment_channels, + ) + .await?; let lifecycle_continuation_facade: Arc = Arc::new( ironclaw_extension_manager::ExtensionHostLifecycleProductService::new(Arc::clone( &skill_management, @@ -1350,6 +1382,7 @@ pub(super) async fn build_backend_production( standalone_wasm_runtime_credential_provider_captured, credential_refresh_worker, channel_extension_bindings, + web_push, deployment_channels, extension_ingress: channel_host_wiring.extension_ingress, channel_pairing: channel_pairing_registry, @@ -1360,6 +1393,125 @@ pub(super) async fn build_backend_production( }) } +/// Install the web-push runtime (subscription store) into the binary's slot +/// and ensure the deployment VAPID credential exists, returning the handles +/// the product surface consumes. `None` when the binary supplied no slot +/// (compositions without the web-push channel). +async fn assemble_web_push( + slot: Option<&ironclaw_web_push::WebPushRuntimeSlot>, + vapid_subject: Option<&str>, + filesystem: &Arc, + secret_store: &Arc, + channel_egress_scope: &ironclaw_host_api::resource::ResourceScope, + deployment_channels: &Arc, +) -> Result, RebornBuildError> +where + S: ironclaw_secrets::SecretStorePort + ?Sized, +{ + let Some(slot) = slot else { + return Ok(None); + }; + // One source of truth for admissible push-service hosts: the channel's + // own manifest egress declarations. An absent deployment binding leaves + // the list empty, so enrollment fails closed rather than admitting + // endpoints delivery could never reach. + let allowed_push_hosts: Vec = deployment_channels + .extension(ironclaw_web_push::WEB_PUSH_EXTENSION_ID) + .and_then(|binding| { + binding.resolved.channel.as_ref().map(|channel| { + channel + .egress + .iter() + .map(|egress| egress.host.clone()) + .collect() + }) + }) + .unwrap_or_default(); + if allowed_push_hosts.is_empty() { + tracing::debug!( + target: "ironclaw::web_push", + "web-push deployment binding is missing or declares no egress hosts; browser \ + enrollment will fail closed" + ); + } + let subscriptions: Arc = + Arc::new(ironclaw_web_push::FilesystemWebPushSubscriptionStore::new( + crate::wrap_scoped(Arc::clone(filesystem)), + )); + slot.install(Arc::new(ironclaw_web_push::WebPushRuntime { + subscriptions: Arc::clone(&subscriptions), + })) + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("web push runtime slot could not be installed: {error}"), + })?; + + let vapid_handle = ironclaw_host_api::ids::SecretHandle::new( + ironclaw_web_push::WEB_PUSH_VAPID_CREDENTIAL_HANDLE, + ) + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("web push VAPID handle is invalid: {error}"), + })?; + let existing = secret_store + .metadata(channel_egress_scope, &vapid_handle) + .await + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("web push VAPID credential lookup failed: {error}"), + })?; + let vapid_public_key = match existing { + Some(_) => { + // Re-derive the (non-secret) public half from the stored + // material so restarts advertise the same applicationServerKey. + let lease = secret_store + .lease_once(channel_egress_scope, &vapid_handle) + .await + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("web push VAPID credential lease failed: {error}"), + })?; + let material = secret_store + .consume(channel_egress_scope, lease.id) + .await + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("web push VAPID credential read failed: {error}"), + })?; + let parsed: ironclaw_host_api::http::VapidCredentialMaterialV1 = serde_json::from_str( + secrecy::ExposeSecret::expose_secret(&material), + ) + .map_err(|_| RebornBuildError::InvalidConfig { + reason: "stored web push VAPID credential material is malformed".to_string(), + })?; + parsed.public_key_b64url + } + None => { + let subject = vapid_subject + .map(str::to_string) + .unwrap_or_else(|| "mailto:webpush@ironclaw.invalid".to_string()); + let generated = + ironclaw_web_push::generate_vapid_key_material(&subject).map_err(|error| { + RebornBuildError::InvalidConfig { + reason: format!("web push VAPID key generation failed: {error}"), + } + })?; + secret_store + .put( + channel_egress_scope.clone(), + vapid_handle.clone(), + ironclaw_secrets::SecretMaterial::from(generated.material_json), + None, + ) + .await + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("web push VAPID credential seeding failed: {error}"), + })?; + generated.public_key_b64url + } + }; + Ok(Some(crate::factory::WebPushComposition { + subscriptions, + vapid_public_key, + allowed_push_hosts, + })) +} + async fn finish_production_backend( context: RebornProductionBuildContext, filesystem: Arc, diff --git a/crates/app/ironclaw_composition/src/factory/production_build_assembly.rs b/crates/app/ironclaw_composition/src/factory/production_build_assembly.rs index 8e75e373856..e0bc10e240f 100644 --- a/crates/app/ironclaw_composition/src/factory/production_build_assembly.rs +++ b/crates/app/ironclaw_composition/src/factory/production_build_assembly.rs @@ -15,6 +15,8 @@ pub(super) async fn build_production_shaped( product_auth_ports, native_extension_factories, channel_extension_bindings, + web_push_runtime_slot, + web_push_vapid_subject, first_party_registrars, credential_account_visibility_policy, #[cfg(any(test, feature = "test-support"))] @@ -83,6 +85,8 @@ pub(super) async fn build_production_shaped( nearai_mcp_bootstrap_config, native_extension_factories, channel_extension_bindings, + web_push_runtime_slot, + web_push_vapid_subject, first_party_bundles, first_party_registrars, credential_account_visibility_policy, @@ -360,6 +364,8 @@ pub(super) struct RebornProductionBuildContext { pub(super) native_extension_factories: Vec>, pub(super) channel_extension_bindings: Vec, + pub(super) web_push_runtime_slot: Option, + pub(super) web_push_vapid_subject: Option, pub(super) first_party_bundles: Vec, pub(super) first_party_registrars: Vec>, diff --git a/crates/app/ironclaw_composition/src/input.rs b/crates/app/ironclaw_composition/src/input.rs index c8f6fd50e8a..190330706db 100644 --- a/crates/app/ironclaw_composition/src/input.rs +++ b/crates/app/ironclaw_composition/src/input.rs @@ -203,6 +203,16 @@ pub struct RebornHostBindings { /// channel host assembly consumes the extras. Composition never names a /// concrete extension crate. pub(crate) channel_extension_bindings: Vec, + /// The web-push channel's late-bound runtime slot (domain crate type, not + /// a concrete extension crate): the binary constructs the adapter around + /// it before storage exists; composition installs the subscription store + /// into it at assembly and seeds the VAPID credential. + pub(crate) web_push_runtime_slot: Option, + /// RFC 8292 `sub` contact URI used when composition seeds the VAPID + /// credential (`mailto:` or `https:`); `None` falls back to a stable + /// placeholder. The binary derives it from the deployment's public base + /// URL when one is configured. + pub(crate) web_push_vapid_subject: Option, /// Binary-assembled first-party capability handler registrars (GSuite, /// web tooling): composition runs each once against the shared registry so /// the concrete executors live in the binary, not composition. @@ -248,6 +258,13 @@ pub struct ChannelExtensionBinding { pub preference_target_codec: Option< std::sync::Arc, >, + /// An extension-owned outbound delivery-target catalog provider (e.g. + /// web-push's constant per-user "Web app" entry). Registered generically + /// into the outbound target registry under the extension id; most channel + /// extensions leave this `None` because the generic channel provider + /// derives their targets from provisioned records. + pub outbound_target_provider: + Option>, } #[derive(Clone, Debug)] @@ -798,6 +815,23 @@ impl RebornHostBindings { self } + /// Hand composition the web-push runtime slot the binary's channel + /// binding already holds, so assembly can install the subscription store + /// and seed the deployment's VAPID credential. + pub fn with_web_push_runtime_slot( + mut self, + slot: ironclaw_web_push::WebPushRuntimeSlot, + ) -> Self { + self.web_push_runtime_slot = Some(slot); + self + } + + /// Operator contact URI for the seeded VAPID credential's `sub` claim. + pub fn with_web_push_vapid_subject(mut self, subject: String) -> Self { + self.web_push_vapid_subject = Some(subject); + self + } + /// Binary-assembled account-setup descriptors (see the field doc). pub fn with_account_setup_descriptors( mut self, @@ -931,6 +965,8 @@ impl RebornHostBindings { product_auth_ports: None, native_extension_factories: Vec::new(), channel_extension_bindings: Vec::new(), + web_push_runtime_slot: None, + web_push_vapid_subject: None, first_party_registrars: Vec::new(), credential_account_visibility_policy: None, memory_binding_policy: None, diff --git a/crates/app/ironclaw_composition/src/lib.rs b/crates/app/ironclaw_composition/src/lib.rs index 0ca78406ae3..cf76bb06b02 100644 --- a/crates/app/ironclaw_composition/src/lib.rs +++ b/crates/app/ironclaw_composition/src/lib.rs @@ -402,6 +402,7 @@ const PER_USER_ALIASES: &[&str] = &[ "/secrets", "/authorization", "/outbound", + "/web-push", "/run-state", "/checkpoint-state", "/approvals", diff --git a/crates/app/ironclaw_composition/src/product_surface.rs b/crates/app/ironclaw_composition/src/product_surface.rs index 76132f1932b..fa149808352 100644 --- a/crates/app/ironclaw_composition/src/product_surface.rs +++ b/crates/app/ironclaw_composition/src/product_surface.rs @@ -251,6 +251,15 @@ pub(crate) fn build_product_surface_with_channel_connection( )), ), )); + if let Some(web_push) = runtime.web_push.as_ref() { + api = api.with_web_push_product_service(Arc::new( + ironclaw_assistant::RebornWebPushProductService::new( + Arc::clone(&web_push.subscriptions), + web_push.vapid_public_key.clone(), + web_push.allowed_push_hosts.clone(), + ), + )); + } if let Some(channel_connection) = channel_connection { api = api.with_channel_connection_service(channel_connection); } diff --git a/crates/app/ironclaw_composition/src/runtime.rs b/crates/app/ironclaw_composition/src/runtime.rs index 13ce0fbba6d..bf3b0ca962f 100644 --- a/crates/app/ironclaw_composition/src/runtime.rs +++ b/crates/app/ironclaw_composition/src/runtime.rs @@ -623,6 +623,7 @@ pub struct RebornRuntime { Option, #[cfg(any(test, feature = "test-support"))] pub(crate) deployment_channels: Arc, + pub(crate) web_push: Option, pub(crate) channel_pairing: Option>, pub(crate) channel_delivery_resolver: Option>, #[cfg(feature = "test-support")] @@ -4298,6 +4299,7 @@ pub(crate) async fn build_runtime_with_resource_governor( extension_ingress: services.extension_ingress.clone(), #[cfg(any(test, feature = "test-support"))] deployment_channels: services.deployment_channels.clone(), + web_push: services.web_push.clone(), channel_pairing: services.channel_pairing.clone(), channel_delivery_resolver: services.channel_delivery_resolver.clone(), #[cfg(feature = "test-support")] diff --git a/crates/app/ironclaw_composition/src/runtime/tests/core.rs b/crates/app/ironclaw_composition/src/runtime/tests/core.rs index ffeda6e5817..fcea3f5b46e 100644 --- a/crates/app/ironclaw_composition/src/runtime/tests/core.rs +++ b/crates/app/ironclaw_composition/src/runtime/tests/core.rs @@ -142,6 +142,7 @@ async fn runtime_channel_identity_bind_uses_deployment_channel_before_user_activ extension_id: ironclaw_host_api::ids::ExtensionId::from_trusted("slack".to_string()), adapter: Arc::new(ironclaw_slack_extension::SlackChannelAdapter), preference_target_codec: None, + outbound_target_provider: None, }]); let input = RebornRuntimeInput::from_build_input(build_input).with_identity(RebornRuntimeIdentity { diff --git a/crates/app/ironclaw_composition/tests/trigger_poller_e2e.rs b/crates/app/ironclaw_composition/tests/trigger_poller_e2e.rs index f8457e636c8..3cf7a977cae 100644 --- a/crates/app/ironclaw_composition/tests/trigger_poller_e2e.rs +++ b/crates/app/ironclaw_composition/tests/trigger_poller_e2e.rs @@ -602,6 +602,7 @@ async fn build_runtime_with_slack_delivery( preference_target_codec: Some(Arc::new( ironclaw_slack_extension::SlackPreferenceTargetCodec, )), + outbound_target_provider: None, }]); let input = RebornRuntimeInput::from_build_input(input) .with_identity(RebornRuntimeIdentity { diff --git a/crates/contracts/ironclaw_extension_contracts/src/channel.rs b/crates/contracts/ironclaw_extension_contracts/src/channel.rs index c9445d19c4d..1a81454f614 100644 --- a/crates/contracts/ironclaw_extension_contracts/src/channel.rs +++ b/crates/contracts/ironclaw_extension_contracts/src/channel.rs @@ -202,6 +202,10 @@ impl ChannelDescriptor { pointer, .. } => pointer.starts_with('/'), + // Field-free: the host derives everything (audience, + // expiry, header value) from the request at the + // injection chokepoint. + ironclaw_host_api::http::RuntimeCredentialTarget::VapidAuthorization => true, }; if !well_formed { return Err(ChannelDescriptorError::InvalidEgressInjection { diff --git a/crates/contracts/ironclaw_host_api/src/http.rs b/crates/contracts/ironclaw_host_api/src/http.rs index 615956b3a16..6bb514e33f5 100644 --- a/crates/contracts/ironclaw_host_api/src/http.rs +++ b/crates/contracts/ironclaw_host_api/src/http.rs @@ -158,6 +158,35 @@ pub enum RuntimeCredentialTarget { #[serde(default, skip_serializing_if = "Option::is_none")] post_injection_body_limit_bytes: Option, }, + /// Compute and inject an RFC 8292 `Authorization: vapid t=,k=` + /// header for a Web Push request. The resolved secret material must be a + /// serialized [`VapidCredentialMaterialV1`]; the host signs an ES256 JWT + /// whose `aud` is the origin of the request URL being sent, so the token + /// is valid only for the push service this request targets. Carries no + /// declaration fields: everything request-dependent is derived host-side + /// at the injection chokepoint, and the adapter never sees key bytes. + VapidAuthorization, +} + +/// The credential-material schema behind +/// [`RuntimeCredentialTarget::VapidAuthorization`] (schema `vapid.v1`): a +/// deployment's Web Push application-server identity per RFC 8292. +/// +/// Stored as one JSON blob under the channel's VAPID credential handle. +/// `es256_private_key_pkcs8_b64url` is secret; the public key and subject +/// are not, but travel inside the same material so the injector needs one +/// resolution. Generation lives in `ironclaw_web_push`; parsing/signing at +/// the host egress credential boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VapidCredentialMaterialV1 { + /// PKCS#8 P-256 private key, base64url (unpadded). + pub es256_private_key_pkcs8_b64url: String, + /// Uncompressed P-256 public key (65 bytes), base64url (unpadded) — the + /// browser-facing `applicationServerKey` and the `k=` parameter. + pub public_key_b64url: String, + /// RFC 8292 `sub` claim: a `mailto:` or `https:` operator contact URI. + pub subject: String, } pub fn valid_http_field_name(name: &str) -> bool { @@ -210,6 +239,10 @@ impl RuntimeCredentialTarget { Self::BodyJsonPointer { pointer, .. } => { validate_runtime_credential_body_pointer(pointer)?; } + // Field-free: audience, expiry, and header value are derived + // host-side from the request being sent and the resolved + // material; there is nothing declared to validate. + Self::VapidAuthorization => {} } Ok(()) } diff --git a/crates/contracts/ironclaw_product_contracts/AGENTS.md b/crates/contracts/ironclaw_product_contracts/AGENTS.md index b07c462109c..e1b58b8e446 100644 --- a/crates/contracts/ironclaw_product_contracts/AGENTS.md +++ b/crates/contracts/ironclaw_product_contracts/AGENTS.md @@ -54,6 +54,7 @@ asserted.* | `operator_secrets` | The operator control plane's secret-**value** port (`OperatorSecretValueStore`) and its opaque error. Implemented by `ironclaw_composition` — assembly is the only layer that may name both this port and `ironclaw_secrets` (PROPOSAL §8.2's product row). **Deliberately not a re-export of `SecretStorePort`:** no `ResourceScope` argument (the implementor fixes the operator scope), no lease/consume protocol, and an error carrying only a `&'static str` classification. Widening it back toward the substrate's shape undoes what CHECKLIST WS3 bought. | | `operator_service` | The deployment-operator control plane's three ports — `OperatorStatusService`, `OperatorLogsService`, `OperatorServiceLifecycleService` — their wire DTOs, and the log-context bound (`normalize_operator_log_context_value`). Implemented by `ironclaw_operator` except readiness status, which is composition's. Product keeps the `Unsupported*`/`Static*` doubles, the frozen view descriptors, and the operator *command-plane* envelope that wraps these DTOs. | | `error` | `ProductOperationFailure` — the error a product-side port fails with, and its projection onto `ProductSurfaceError`. Product's `ProductSurfaceFailure` is the superset and absorbs it; see the ruling below. | +| `web_push` | The web-push enrollment operation descriptors: `WEB_PUSH_STATUS_VIEW` and the `web_push.subscribe` / `web_push.unsubscribe` command descriptors (+ ids). Descriptors only — enrollment behavior, storage, and VAPID custody stay in `ironclaw_assistant` / `ironclaw_web_push` / composition. | | `subject_route` | `ProductConversationSubjectRouteResolver` + `ProductConversationRouteKey` and its request. Shared-route subject resolution, implemented by `ironclaw_extension_host` over `[channel.config]`. | ## What must never be here diff --git a/crates/contracts/ironclaw_product_contracts/src/lib.rs b/crates/contracts/ironclaw_product_contracts/src/lib.rs index 37364a3dc71..d41fc75af8d 100644 --- a/crates/contracts/ironclaw_product_contracts/src/lib.rs +++ b/crates/contracts/ironclaw_product_contracts/src/lib.rs @@ -65,6 +65,7 @@ pub mod surface; #[cfg(any(test, feature = "test-support"))] pub mod test_support; pub mod views; +pub mod web_push; pub mod workspace_views; // There is deliberately no flat prelude and no cross-module re-export here. diff --git a/crates/contracts/ironclaw_product_contracts/src/product_wire.rs b/crates/contracts/ironclaw_product_contracts/src/product_wire.rs index 627279afc35..0ea8feff640 100644 --- a/crates/contracts/ironclaw_product_contracts/src/product_wire.rs +++ b/crates/contracts/ironclaw_product_contracts/src/product_wire.rs @@ -955,6 +955,72 @@ pub struct RebornNotificationChannelsResponse { pub channels: Vec, } +/// Browser push keys from `PushSubscription.getKey()`, base64url. Validated +/// shapes (65-byte uncompressed P-256 point / 16-byte auth secret) are +/// enforced by the web-push domain at the service boundary; the wire carries +/// the raw strings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RebornWebPushSubscriptionKeys { + pub p256dh: String, + pub auth: String, +} + +/// Enroll (or refresh) the calling user's current browser for web push. +/// The endpoint is the push-service capability URL the browser minted; the +/// service validates scheme/host against the supported push-service +/// allowlist before persisting. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RebornWebPushSubscribeRequest { + pub endpoint: String, + pub keys: RebornWebPushSubscriptionKeys, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_agent: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RebornWebPushSubscribeOutcome { + Enrolled, + Refreshed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RebornWebPushSubscribeResponse { + pub outcome: RebornWebPushSubscribeOutcome, +} + +/// Remove one browser enrollment by its push endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RebornWebPushUnsubscribeRequest { + pub endpoint: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RebornWebPushUnsubscribeResponse { + pub removed: bool, +} + +/// One enrolled browser, redacted for the settings surface: the endpoint is +/// a bearer capability URL, so only its push-service host is projected. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RebornWebPushSubscriptionInfo { + pub subscription_id: String, + pub endpoint_host: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_agent: Option, + pub created_at: String, +} + +/// The caller's web-push enrollment state plus the deployment's public VAPID +/// key (`applicationServerKey` for `PushManager.subscribe`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RebornWebPushStatusResponse { + pub vapid_public_key: String, + pub subscription_count: u32, + #[serde(default)] + pub subscriptions: Vec, +} + /// Allowlisted terminal status exposed by automation list projections. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/contracts/ironclaw_product_contracts/src/web_push.rs b/crates/contracts/ironclaw_product_contracts/src/web_push.rs new file mode 100644 index 00000000000..b8fe345f448 --- /dev/null +++ b/crates/contracts/ironclaw_product_contracts/src/web_push.rs @@ -0,0 +1,38 @@ +//! Web-push enrollment operation descriptors: the status view and the +//! subscribe/unsubscribe product commands the WebUI settings panel drives. +//! +//! Declared here (not in the product crate) per the transport/product +//! boundary: transports consume the product boundary, and new feature +//! descriptors join the contracts tier rather than growing the frozen +//! webui→assistant symbol residue (same placement as +//! [`crate::ironhub`]'s command descriptor). The *behavior* — enrollment +//! validation, storage, VAPID custody — stays in `ironclaw_assistant` and +//! the web-push domain/channel crates. + +use crate::descriptors::{ProductSurfaceCommandDescriptor, ProductView}; +use crate::product_wire::{ + RebornWebPushStatusResponse, RebornWebPushSubscribeRequest, RebornWebPushSubscribeResponse, + RebornWebPushUnsubscribeRequest, RebornWebPushUnsubscribeResponse, +}; + +/// WebUI-facing read of the deployment VAPID public key and the caller's +/// enrolled browsers. +pub const WEB_PUSH_STATUS_VIEW: ProductView = + ProductView::unpaginated("web_push_status"); + +/// Enroll/refresh the caller's current browser for web push. A webui-only +/// authenticated settings write (same class as the notification-channels +/// command); the browser mints the subscription and the service validates it +/// against the deployment's declared push-service hosts. +pub const WEB_PUSH_SUBSCRIBE_COMMAND_ID: &str = "web_push.subscribe"; +pub const WEB_PUSH_SUBSCRIBE_COMMAND: ProductSurfaceCommandDescriptor< + RebornWebPushSubscribeRequest, + RebornWebPushSubscribeResponse, +> = ProductSurfaceCommandDescriptor::new(WEB_PUSH_SUBSCRIBE_COMMAND_ID); + +/// Remove one of the caller's browser enrollments by endpoint. +pub const WEB_PUSH_UNSUBSCRIBE_COMMAND_ID: &str = "web_push.unsubscribe"; +pub const WEB_PUSH_UNSUBSCRIBE_COMMAND: ProductSurfaceCommandDescriptor< + RebornWebPushUnsubscribeRequest, + RebornWebPushUnsubscribeResponse, +> = ProductSurfaceCommandDescriptor::new(WEB_PUSH_UNSUBSCRIBE_COMMAND_ID); diff --git a/crates/domains/AGENTS.md b/crates/domains/AGENTS.md index acb98e217a5..63ded3c0636 100644 --- a/crates/domains/AGENTS.md +++ b/crates/domains/AGENTS.md @@ -1,8 +1,8 @@ # `crates/domains/` — one crate per business-record grammar, no authority decisions -**Layer(s):** `substrates` (all 12 manifests declare it; checked by +**Layer(s):** `substrates` (all 13 manifests declare it; checked by `reborn_workspace_crates_declare_layers_and_follow_layer_matrix`) · -**Crates:** 12 · **May depend on:** `contracts/`, `substrates/`, `events/`, +**Crates:** 13 · **May depend on:** `contracts/`, `substrates/`, `events/`, plus five inventoried in-family edges (below) · **Depended on by:** `kernel/`, `loop/`, `extensions/`, `product/`, `app/` — every tier above wires against these contracts. @@ -36,6 +36,7 @@ no other crate in the family may acquire either property. | [`ironclaw_threads`](./ironclaw_threads) | The canonical transcript service: `SessionThreadService` (filesystem + in-memory), message ordering/status/redaction, tool-result records, display projections | Reading or writing thread/message history or transcript-derived views | | [`ironclaw_trace_commons`](./ironclaw_trace_commons) | The Trace Commons client: envelope schema, deterministic redaction, submission queue/credits, device-key onboarding, and the autonomous capture pipeline | Contributing traces to the external Trace Commons service | | [`ironclaw_triggers`](./ironclaw_triggers) | Scheduled-trigger records, cron/timezone validation, deterministic fire identity, the poller tick, and sealed trusted-submission minting (prompt-scanned at the mint); SQL backends held under ADR 0003 | Trigger records/schedules, or anything on the host-trusted fire path | +| [`ironclaw_web_push`](./ironclaw_web_push) | Web Push (RFC 8030/8291/8292) subscription records + CAS store, `aes128gcm` payload encryption, VAPID key-material generation, transport-free push request planning, and the browser channel's identity grammar | Browser push enrollment records or push protocol mechanics (delivery runs through the web-push channel package; the VAPID header is computed at the host egress boundary) | Two boundary facts that have been gotten wrong before, stated precisely: diff --git a/crates/domains/ironclaw_web_push/Cargo.toml b/crates/domains/ironclaw_web_push/Cargo.toml new file mode 100644 index 00000000000..29d00afd882 --- /dev/null +++ b/crates/domains/ironclaw_web_push/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ironclaw_web_push" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +description = "Web Push (RFC 8030/8291/8292) subscription records, payload encryption, and request planning for the web app notification channel" +publish = false + +[package.metadata.ironclaw] +layer = "substrates" + +[dependencies] +async-trait = "0.1" +# ES256 keygen (VAPID) + ECDH P-256 / HKDF-SHA256 / AES-128-GCM (RFC 8291 +# aes128gcm content encryption). Already linked workspace-wide via +# jsonwebtoken's aws_lc_rs backend; no new lockfile entry. +aws-lc-rs = "1" +base64 = "0.23" +ironclaw_filesystem = { path = "../../substrates/ironclaw_filesystem" } +ironclaw_host_api = { path = "../../contracts/ironclaw_host_api" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +url = "2" +uuid = { version = "1", features = ["v4", "serde"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/domains/ironclaw_web_push/README.md b/crates/domains/ironclaw_web_push/README.md new file mode 100644 index 00000000000..c1df58c66a6 --- /dev/null +++ b/crates/domains/ironclaw_web_push/README.md @@ -0,0 +1,39 @@ +# ironclaw_web_push + +Web Push (RFC 8030/8291/8292) domain crate: the record grammar and pure +mechanics behind the web app's browser-notification channel. + +## What it owns + +- `PushSubscriptionRecord` / `PushEndpoint` / `PushSubscriptionKeys` — the + validated per-browser enrollment grammar, including the exact-host push + service allowlist (`SUPPORTED_PUSH_SERVICE_HOSTS`). +- `WebPushSubscriptionStore` + the scoped-filesystem implementation — one + CAS-updated JSON document per (tenant, user), capped at + `MAX_SUBSCRIPTIONS_PER_USER` browsers. +- RFC 8291 `aes128gcm` payload encryption (`encrypt_payload`), pinned to the + RFC's Appendix A vector. +- VAPID key material generation (`generate_vapid_key_material`) producing the + `VapidCredentialMaterialV1` JSON the host egress injector consumes. +- `build_push_request` — subscription + payload → a transport-free + `WebPushRequestPlan` (host, origin-form path, protocol headers, encrypted + body). +- The channel identity grammar (`web-push` extension id, the constant + owner-scoped `web-push` target id, and the `web-push/v1//` + reply-target binding-ref format). +- `WebPushRuntimeSlot` — the late-bound handle the binary's adapter binding + holds until composition installs storage at assembly. + +## When you want a different crate + +- Sending the planned request: the channel package + (`crates/extensions/packages/web-push`) over restricted egress. +- The `Authorization: vapid` header: computed host-side at the egress + credential boundary (`ironclaw_host_runtime`), never here. +- Notification routing/policy: `ironclaw_outbound`. +- Subscribe/unsubscribe product surface: `ironclaw_assistant` + WebUI. + +## Validation + +- `cargo test -p ironclaw_web_push` +- `cargo clippy -p ironclaw_web_push --all-targets --all-features -- -D warnings` diff --git a/crates/domains/ironclaw_web_push/src/crypto.rs b/crates/domains/ironclaw_web_push/src/crypto.rs new file mode 100644 index 00000000000..3c3b63fedd8 --- /dev/null +++ b/crates/domains/ironclaw_web_push/src/crypto.rs @@ -0,0 +1,365 @@ +//! RFC 8291 (`aes128gcm`) Web Push payload encryption. +//! +//! One record per message: ECDH(P-256) against the subscription's `p256dh` +//! key, HKDF-SHA256 through the RFC 8291 info strings, AES-128-GCM over +//! `plaintext || 0x02`, framed per RFC 8188 as +//! `salt(16) || rs(4) || idlen(1) || as_public(65) || ciphertext`. +//! +//! All primitives come from `aws-lc-rs` (already linked workspace-wide). +//! Encryption uses only the *recipient's* public material plus a fresh +//! ephemeral keypair and salt — no stored secret is involved, which is why +//! this lives in the domain crate while VAPID signing stays at the host +//! egress boundary. + +use aws_lc_rs::{aead, agreement, hkdf, rand as aws_rand}; + +use crate::error::WebPushError; + +/// RFC 8188 record size we declare in the header. Push services cap the +/// whole body at 4096 bytes, so a single 4096-byte record always suffices. +pub const RECORD_SIZE: u32 = 4096; + +/// Total encrypted-body budget push services commonly enforce (RFC 8030 §7.2 +/// suggests supporting at least 4096 bytes; FCM/Mozilla/Apple all cap there). +pub const MAX_ENCRYPTED_BODY_BYTES: usize = 4096; + +/// Header: salt(16) + rs(4) + idlen(1) + uncompressed P-256 point (65). +const HEADER_BYTES: usize = 16 + 4 + 1 + 65; +const TAG_BYTES: usize = 16; +/// The single-record padding delimiter (`0x02` marks the final record). +const PAD_DELIMITER_BYTES: usize = 1; + +/// Largest plaintext that still fits the 4096-byte body budget. +pub const MAX_PLAINTEXT_BYTES: usize = + MAX_ENCRYPTED_BODY_BYTES - HEADER_BYTES - TAG_BYTES - PAD_DELIMITER_BYTES; + +const KEY_INFO_PREFIX: &[u8] = b"WebPush: info\0"; +const CEK_INFO: &[u8] = b"Content-Encoding: aes128gcm\0"; +const NONCE_INFO: &[u8] = b"Content-Encoding: nonce\0"; + +struct HkdfOutputLen(usize); + +impl hkdf::KeyType for HkdfOutputLen { + fn len(&self) -> usize { + self.0 + } +} + +/// Encrypt `plaintext` for the subscription identified by `ua_public` +/// (65-byte uncompressed P-256 point) and `auth_secret` (16 bytes), using a +/// fresh ephemeral keypair and salt. Returns the complete `aes128gcm` body. +pub fn encrypt_payload( + ua_public: &[u8], + auth_secret: &[u8], + plaintext: &[u8], +) -> Result, WebPushError> { + let rng = aws_rand::SystemRandom::new(); + let mut salt = [0u8; 16]; + aws_rand::SecureRandom::fill(&rng, &mut salt) + .map_err(|_| WebPushError::crypto("salt generation failed"))?; + let as_private = agreement::PrivateKey::generate(&agreement::ECDH_P256) + .map_err(|_| WebPushError::crypto("ephemeral key generation failed"))?; + encrypt_with_materials(&as_private, salt, ua_public, auth_secret, plaintext) +} + +/// Deterministic core, split out so the RFC 8291 appendix vector can drive +/// it with the fixed ephemeral key and salt. +pub(crate) fn encrypt_with_materials( + as_private: &agreement::PrivateKey, + salt: [u8; 16], + ua_public: &[u8], + auth_secret: &[u8], + plaintext: &[u8], +) -> Result, WebPushError> { + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(WebPushError::PayloadTooLarge { + bytes: plaintext.len(), + limit: MAX_PLAINTEXT_BYTES, + }); + } + if ua_public.len() != 65 || ua_public[0] != 0x04 { + return Err(WebPushError::InvalidSubscription { + reason: "p256dh must be a 65-byte uncompressed P-256 point".to_string(), + }); + } + if auth_secret.len() != 16 { + return Err(WebPushError::InvalidSubscription { + reason: "auth must be 16 bytes".to_string(), + }); + } + + let as_public = as_private + .compute_public_key() + .map_err(|_| WebPushError::crypto("ephemeral public key derivation failed"))?; + let as_public_bytes = as_public.as_ref(); + if as_public_bytes.len() != 65 { + return Err(WebPushError::crypto( + "ephemeral public key is not an uncompressed P-256 point", + )); + } + + let peer = agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, ua_public); + let ecdh_secret = agreement::agree( + as_private, + peer, + WebPushError::crypto("ECDH agreement failed"), + |secret| Ok(secret.to_vec()), + )?; + + let (cek, nonce) = + derive_cek_and_nonce(&ecdh_secret, auth_secret, ua_public, as_public_bytes, &salt)?; + + // record = plaintext || 0x02 (final-record padding delimiter). + let mut record = Vec::with_capacity(plaintext.len() + PAD_DELIMITER_BYTES + TAG_BYTES); + record.extend_from_slice(plaintext); + record.push(0x02); + + let unbound = aead::UnboundKey::new(&aead::AES_128_GCM, &cek) + .map_err(|_| WebPushError::crypto("content-encryption key rejected"))?; + let key = aead::LessSafeKey::new(unbound); + key.seal_in_place_append_tag( + aead::Nonce::assume_unique_for_key(nonce), + aead::Aad::empty(), + &mut record, + ) + .map_err(|_| WebPushError::crypto("payload sealing failed"))?; + + let mut body = Vec::with_capacity(HEADER_BYTES + record.len()); + body.extend_from_slice(&salt); + body.extend_from_slice(&RECORD_SIZE.to_be_bytes()); + body.push(65u8); + body.extend_from_slice(as_public_bytes); + body.extend_from_slice(&record); + Ok(body) +} + +/// RFC 8291 §3.3-3.4: two HKDF stages from the ECDH secret to CEK + nonce. +fn derive_cek_and_nonce( + ecdh_secret: &[u8], + auth_secret: &[u8], + ua_public: &[u8], + as_public: &[u8], + salt: &[u8; 16], +) -> Result<([u8; 16], [u8; 12]), WebPushError> { + // IKM = HKDF(salt=auth_secret, ikm=ecdh_secret, info="WebPush: info\0" || ua_public || as_public, 32) + let mut ikm = [0u8; 32]; + hkdf_expand( + auth_secret, + ecdh_secret, + &[KEY_INFO_PREFIX, ua_public, as_public], + &mut ikm, + )?; + // CEK = HKDF(salt, IKM, "Content-Encoding: aes128gcm\0", 16) + let mut cek = [0u8; 16]; + hkdf_expand(salt, &ikm, &[CEK_INFO], &mut cek)?; + // NONCE = HKDF(salt, IKM, "Content-Encoding: nonce\0", 12) + let mut nonce = [0u8; 12]; + hkdf_expand(salt, &ikm, &[NONCE_INFO], &mut nonce)?; + Ok((cek, nonce)) +} + +fn hkdf_expand( + salt: &[u8], + ikm: &[u8], + info: &[&[u8]], + out: &mut [u8], +) -> Result<(), WebPushError> { + let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, salt).extract(ikm); + let okm = prk + .expand(info, HkdfOutputLen(out.len())) + .map_err(|_| WebPushError::crypto("HKDF expand failed"))?; + okm.fill(out) + .map_err(|_| WebPushError::crypto("HKDF fill failed"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + + /// Wrap a raw P-256 scalar in a PKCS#8 `ECPrivateKey` DER (no embedded + /// public key) so the RFC vector's fixed keys can drive the deterministic + /// core. Fixed template per RFC 5208/5915. + fn p256_pkcs8_from_scalar(scalar: &[u8]) -> Vec { + assert_eq!(scalar.len(), 32, "P-256 scalar is 32 bytes"); + let mut der = Vec::with_capacity(67); + der.extend_from_slice(&[0x30, 0x41, 0x02, 0x01, 0x00]); + // AlgorithmIdentifier: ecPublicKey + prime256v1. + der.extend_from_slice(&[ + 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, + 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, + ]); + // OCTET STRING { ECPrivateKey { version 1, privateKey } } + der.extend_from_slice(&[0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20]); + der.extend_from_slice(scalar); + der + } + + fn b64(value: &str) -> Vec { + URL_SAFE_NO_PAD.decode(value).expect("test vector base64") + } + + /// RFC 8291 Appendix A inputs. + struct Rfc8291Vector { + ua_public: Vec, + ua_private_scalar: Vec, + as_private_scalar: Vec, + auth: Vec, + salt: [u8; 16], + plaintext: &'static [u8], + } + + fn vector() -> Rfc8291Vector { + let salt_bytes = b64("DGv6ra1nlYgDCS1FRnbzlw"); + let mut salt = [0u8; 16]; + salt.copy_from_slice(&salt_bytes); + Rfc8291Vector { + ua_public: b64( + "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", + ), + ua_private_scalar: b64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"), + as_private_scalar: b64("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"), + auth: b64("BTBZMqHH6r4Tts7J_aSIgg"), + salt, + plaintext: b"When I grow up, I want to be a watermelon", + } + } + + #[test] + fn rfc8291_appendix_a_intermediate_values_match() { + let vector = vector(); + let as_private = agreement::PrivateKey::from_private_key_der( + &agreement::ECDH_P256, + &p256_pkcs8_from_scalar(&vector.as_private_scalar), + ) + .expect("vector private key parses"); + let as_public = as_private.compute_public_key().expect("public key"); + assert_eq!( + URL_SAFE_NO_PAD.encode(as_public.as_ref()), + "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8", + "as_public must match the vector" + ); + + let peer = agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, &vector.ua_public); + let ecdh_secret = agreement::agree( + &as_private, + peer, + WebPushError::crypto("agree failed"), + |secret| Ok(secret.to_vec()), + ) + .expect("ECDH agreement"); + assert_eq!( + URL_SAFE_NO_PAD.encode(&ecdh_secret), + "kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs", + "ecdh_secret must match the vector" + ); + + let (cek, nonce) = derive_cek_and_nonce( + &ecdh_secret, + &vector.auth, + &vector.ua_public, + as_public.as_ref(), + &vector.salt, + ) + .expect("derivation"); + assert_eq!(URL_SAFE_NO_PAD.encode(cek), "oIhVW04MRdy2XN9CiKLxTg"); + assert_eq!(URL_SAFE_NO_PAD.encode(nonce), "4h_95klXJ5E_qnoN"); + } + + #[test] + fn rfc8291_appendix_a_body_round_trips_and_frames_correctly() { + let vector = vector(); + let as_private = agreement::PrivateKey::from_private_key_der( + &agreement::ECDH_P256, + &p256_pkcs8_from_scalar(&vector.as_private_scalar), + ) + .expect("vector private key parses"); + let body = encrypt_with_materials( + &as_private, + vector.salt, + &vector.ua_public, + &vector.auth, + vector.plaintext, + ) + .expect("encryption succeeds"); + + // Header framing: salt || rs=4096 || idlen=65 || as_public. + assert_eq!(&body[..16], vector.salt.as_slice()); + assert_eq!(&body[16..20], &4096u32.to_be_bytes()); + assert_eq!(body[20], 65); + assert_eq!( + URL_SAFE_NO_PAD.encode(&body[21..86]), + "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8" + ); + assert_eq!( + body.len(), + HEADER_BYTES + vector.plaintext.len() + PAD_DELIMITER_BYTES + TAG_BYTES, + ); + + // Receiver side: decrypt with the vector's UA private key. + let ua_private = agreement::PrivateKey::from_private_key_der( + &agreement::ECDH_P256, + &p256_pkcs8_from_scalar(&vector.ua_private_scalar), + ) + .expect("ua private key parses"); + let as_public_bytes = body[21..86].to_vec(); + let peer = agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, &as_public_bytes); + let ecdh_secret = agreement::agree( + &ua_private, + peer, + WebPushError::crypto("agree failed"), + |secret| Ok(secret.to_vec()), + ) + .expect("receiver ECDH"); + let (cek, nonce) = derive_cek_and_nonce( + &ecdh_secret, + &vector.auth, + &vector.ua_public, + &as_public_bytes, + &vector.salt, + ) + .expect("receiver derivation"); + + let unbound = aead::UnboundKey::new(&aead::AES_128_GCM, &cek).expect("cek"); + let key = aead::LessSafeKey::new(unbound); + let mut ciphertext = body[86..].to_vec(); + let opened = key + .open_in_place( + aead::Nonce::assume_unique_for_key(nonce), + aead::Aad::empty(), + &mut ciphertext, + ) + .expect("decryption succeeds"); + assert_eq!(opened.last(), Some(&0x02u8), "final-record delimiter"); + assert_eq!(&opened[..opened.len() - 1], vector.plaintext); + } + + #[test] + fn random_path_produces_unique_bodies_that_fit_the_budget() { + let vector = vector(); + let first = encrypt_payload(&vector.ua_public, &vector.auth, b"hello").expect("encrypts"); + let second = encrypt_payload(&vector.ua_public, &vector.auth, b"hello").expect("encrypts"); + assert_ne!(first, second, "fresh salt + ephemeral key per message"); + assert!(first.len() <= MAX_ENCRYPTED_BODY_BYTES); + } + + #[test] + fn oversized_plaintext_fails_closed() { + let vector = vector(); + let oversized = vec![0u8; MAX_PLAINTEXT_BYTES + 1]; + assert!(matches!( + encrypt_payload(&vector.ua_public, &vector.auth, &oversized), + Err(WebPushError::PayloadTooLarge { .. }) + )); + } + + #[test] + fn malformed_recipient_material_fails_closed() { + let vector = vector(); + assert!(encrypt_payload(&[0u8; 65], &vector.auth, b"x").is_err()); + assert!(encrypt_payload(&vector.ua_public[..64], &vector.auth, b"x").is_err()); + assert!(encrypt_payload(&vector.ua_public, &[0u8; 5], b"x").is_err()); + } +} diff --git a/crates/domains/ironclaw_web_push/src/error.rs b/crates/domains/ironclaw_web_push/src/error.rs new file mode 100644 index 00000000000..f9bd8f31e70 --- /dev/null +++ b/crates/domains/ironclaw_web_push/src/error.rs @@ -0,0 +1,52 @@ +//! Typed web-push domain failures. Reasons are sanitized summaries — no +//! endpoint URLs, key material, or backend error internals cross this +//! boundary (push endpoints are capability URLs and are treated as +//! sensitive). + +/// Web-push domain errors. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum WebPushError { + /// A subscription payload from the browser failed validation. + #[error("push subscription is invalid: {reason}")] + InvalidSubscription { reason: String }, + /// The subscription endpoint's push service is not on the supported + /// allowlist. Carries only the host, never the full capability URL. + #[error("push service host is not supported: {host}")] + UnsupportedPushService { host: String }, + /// The payload does not fit the single-record aes128gcm budget. + #[error("push payload of {bytes} bytes exceeds the {limit}-byte limit")] + PayloadTooLarge { bytes: usize, limit: usize }, + /// A cryptographic operation failed. The reason names the operation, + /// never material. + #[error("web push crypto failure: {reason}")] + Crypto { reason: String }, + /// The subscription store rejected or failed the operation. + #[error("web push subscription store failure: {reason}")] + Store { reason: String }, + /// The per-user subscription cap would be exceeded. + #[error("subscription limit of {limit} browsers reached")] + SubscriptionLimitReached { limit: usize }, + /// A caller-supplied scope or identity failed validation. + #[error("web push scope is invalid: {reason}")] + InvalidScope { reason: String }, + /// The runtime slot has not been installed yet (boot ordering). + #[error("web push runtime is not available yet")] + RuntimeUnavailable, + /// The runtime slot was installed twice. + #[error("web push runtime is already installed")] + RuntimeAlreadyInstalled, +} + +impl WebPushError { + pub fn store(source: impl std::fmt::Display) -> Self { + Self::Store { + reason: source.to_string(), + } + } + + pub fn crypto(reason: impl Into) -> Self { + Self::Crypto { + reason: reason.into(), + } + } +} diff --git a/crates/domains/ironclaw_web_push/src/grammar.rs b/crates/domains/ironclaw_web_push/src/grammar.rs new file mode 100644 index 00000000000..84b023bcc89 --- /dev/null +++ b/crates/domains/ironclaw_web_push/src/grammar.rs @@ -0,0 +1,99 @@ +//! The web-push channel's identity grammar: extension/channel names, the +//! constant owner-scoped catalog target id, and the reply-target binding-ref +//! format. Both halves of the grammar (encode + decode) live here so the +//! channel package's codec, target provider, and adapter cannot drift from +//! each other. + +use ironclaw_host_api::ids::{TenantId, UserId}; +use ironclaw_host_api::turn::ReplyTargetBindingRef; + +use crate::error::WebPushError; + +/// The extension identity the channel binding, manifest, and delivery +/// resolution share. Deliberately distinct from the retired `builtin:web_app` +/// pseudo-target: this channel is real external egress to push services. +pub const WEB_PUSH_EXTENSION_ID: &str = "web-push"; + +/// Catalog channel label shown beside the target in pickers. +pub const WEB_PUSH_CHANNEL_NAME: &str = "web-push"; + +/// The constant, owner-scoped catalog target id. Target resolution is always +/// scoped to the requesting owner, so one stable id per user is unambiguous +/// ("this user's enrolled browsers"). +pub const WEB_PUSH_TARGET_ID: &str = "web-push"; + +/// Credential handle for the deployment's VAPID key material, referenced by +/// the manifest's `[[channel.egress]]` declarations and seeded at boot. +pub const WEB_PUSH_VAPID_CREDENTIAL_HANDLE: &str = "web_push_vapid"; + +const REF_PREFIX: &str = "web-push/v1/"; + +/// Encode the reply-target binding ref for one user's browsers: +/// `web-push/v1//`. +pub fn encode_web_push_target_ref( + tenant_id: &TenantId, + user_id: &UserId, +) -> Result { + let tenant = tenant_id.to_string(); + if tenant.contains('/') { + // Tenant ids never carry '/' today; refuse rather than mint an + // ambiguous ref if that ever changes. + return Err(WebPushError::InvalidScope { + reason: "tenant id cannot be encoded into a web-push target ref".to_string(), + }); + } + ReplyTargetBindingRef::new(format!("{REF_PREFIX}{tenant}/{user_id}")).map_err(|error| { + WebPushError::InvalidScope { + reason: format!("web-push target ref rejected: {error}"), + } + }) +} + +/// Decode a binding ref minted by [`encode_web_push_target_ref`]. +pub fn decode_web_push_target_ref(reference: &str) -> Option<(TenantId, UserId)> { + let remainder = reference.strip_prefix(REF_PREFIX)?; + let (tenant, user) = remainder.split_once('/')?; + let tenant_id = TenantId::new(tenant).ok()?; + let user_id = UserId::new(user).ok()?; + Some((tenant_id, user_id)) +} + +/// Whether a binding ref belongs to the web-push grammar at all. +pub fn is_web_push_target_ref(reference: &str) -> bool { + reference.starts_with(REF_PREFIX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binding_refs_round_trip() { + let tenant = TenantId::new("tenant1").expect("tenant"); + let user = UserId::new("user-42").expect("user"); + let reference = encode_web_push_target_ref(&tenant, &user).expect("encode"); + assert_eq!(reference.as_str(), "web-push/v1/tenant1/user-42"); + let (decoded_tenant, decoded_user) = + decode_web_push_target_ref(reference.as_str()).expect("decode"); + assert_eq!(decoded_tenant, tenant); + assert_eq!(decoded_user, user); + assert!(is_web_push_target_ref(reference.as_str())); + } + + #[test] + fn foreign_refs_do_not_decode() { + for foreign in [ + "slack/v1/team/channel", + "web-push/v2/tenant1/user", + "web-push/v1/", + "web-push/v1/tenant-only", + "builtin:web_app", + ] { + assert!( + decode_web_push_target_ref(foreign).is_none(), + "{foreign:?} must not decode" + ); + } + assert!(!is_web_push_target_ref("slack/v1/team/channel")); + } +} diff --git a/crates/domains/ironclaw_web_push/src/lib.rs b/crates/domains/ironclaw_web_push/src/lib.rs new file mode 100644 index 00000000000..667522d48b8 --- /dev/null +++ b/crates/domains/ironclaw_web_push/src/lib.rs @@ -0,0 +1,43 @@ +//! Web Push domain: subscription records and storage, RFC 8291 payload +//! encryption, VAPID key material generation, and push request planning for +//! the web app's browser-notification channel. +//! +//! Boundaries (family rules apply): +//! - **No transport.** This crate plans requests (`WebPushRequestPlan`) and +//! never sends them; the channel adapter drives restricted egress and the +//! host injects the `Authorization: vapid` header at the egress credential +//! boundary. +//! - **No secret custody.** VAPID material is generated here, stored by +//! composition through the channel credential path, and only ever read +//! back by the host egress injector. +//! - **Storage** rides the scoped filesystem plane through the shared +//! bounded CAS path; composition chooses backends. + +pub mod crypto; +pub mod error; +pub mod grammar; +pub mod message; +pub mod runtime; +pub mod store; +pub mod subscription; +pub mod vapid; + +pub use crypto::{MAX_ENCRYPTED_BODY_BYTES, MAX_PLAINTEXT_BYTES, encrypt_payload}; +pub use error::WebPushError; +pub use grammar::{ + WEB_PUSH_CHANNEL_NAME, WEB_PUSH_EXTENSION_ID, WEB_PUSH_TARGET_ID, + WEB_PUSH_VAPID_CREDENTIAL_HANDLE, decode_web_push_target_ref, encode_web_push_target_ref, + is_web_push_target_ref, +}; +pub use message::{ + DEFAULT_TTL_SECONDS, PushUrgency, WebPushNotificationPayload, WebPushRequestPlan, + build_push_request, +}; +pub use runtime::{WebPushRuntime, WebPushRuntimeSlot}; +pub use store::{ + FilesystemWebPushSubscriptionStore, PushSubscriptionUpsertOutcome, WebPushSubscriptionStore, +}; +pub use subscription::{ + MAX_SUBSCRIPTIONS_PER_USER, PushEndpoint, PushSubscriptionKeys, PushSubscriptionRecord, +}; +pub use vapid::{GeneratedVapidKeyMaterial, generate_vapid_key_material, validate_vapid_subject}; diff --git a/crates/domains/ironclaw_web_push/src/message.rs b/crates/domains/ironclaw_web_push/src/message.rs new file mode 100644 index 00000000000..c863441d11a --- /dev/null +++ b/crates/domains/ironclaw_web_push/src/message.rs @@ -0,0 +1,219 @@ +//! Notification payload schema and push request planning. +//! +//! The payload JSON is the contract between the server and the web app's +//! service worker (`frontend/public/sw.js` parses exactly these fields). +//! The request plan is pure data — host, origin-form path, headers, and the +//! encrypted body. Transport and the `Authorization: vapid` header stay +//! host-side (the adapter tags the egress request with the VAPID credential +//! handle; the host computes and injects the header). + +use serde::{Deserialize, Serialize}; + +use crate::crypto; +use crate::error::WebPushError; +use crate::subscription::PushSubscriptionRecord; + +/// Default TTL for notification pushes: one day. Long enough to survive an +/// offline laptop lid-close, short enough not to replay stale automation +/// noise days later. +pub const DEFAULT_TTL_SECONDS: u32 = 86_400; + +/// Soft cap for the serialized payload JSON, leaving margin under the +/// single-record plaintext budget. +pub const MAX_PAYLOAD_JSON_BYTES: usize = 3_800; + +const MAX_TITLE_CHARS: usize = 120; +const MAX_BODY_CHARS: usize = 1_500; +const ELLIPSIS: char = '…'; + +/// RFC 8030 §5.3 urgency. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PushUrgency { + VeryLow, + Low, + Normal, + High, +} + +impl PushUrgency { + pub fn header_value(self) -> &'static str { + match self { + Self::VeryLow => "very-low", + Self::Low => "low", + Self::Normal => "normal", + Self::High => "high", + } + } +} + +/// What the service worker receives after the browser decrypts the push. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebPushNotificationPayload { + pub title: String, + pub body: String, + /// App-relative deep link the notification opens (e.g. `/automations`). + pub url: String, + /// Coalescing tag: notifications with the same tag replace each other. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tag: Option, +} + +impl WebPushNotificationPayload { + /// Build a payload, truncating title/body to their display budgets. + pub fn new( + title: impl Into, + body: impl Into, + url: impl Into, + tag: Option, + ) -> Self { + Self { + title: truncate_chars(&sanitize_text(title.into()), MAX_TITLE_CHARS), + body: truncate_chars(&sanitize_text(body.into()), MAX_BODY_CHARS), + url: url.into(), + tag, + } + } + + pub fn to_json_bytes(&self) -> Result, WebPushError> { + let bytes = serde_json::to_vec(self) + .map_err(|error| WebPushError::crypto(format!("payload serialization: {error}")))?; + if bytes.len() > MAX_PAYLOAD_JSON_BYTES { + return Err(WebPushError::PayloadTooLarge { + bytes: bytes.len(), + limit: MAX_PAYLOAD_JSON_BYTES, + }); + } + Ok(bytes) + } +} + +fn sanitize_text(raw: String) -> String { + raw.chars() + .map(|character| { + if character == '\n' || character == '\t' { + character + } else if character.is_control() { + ' ' + } else { + character + } + }) + .collect() +} + +fn truncate_chars(raw: &str, max_chars: usize) -> String { + if raw.chars().count() <= max_chars { + return raw.to_string(); + } + let mut truncated: String = raw.chars().take(max_chars.saturating_sub(1)).collect(); + truncated.push(ELLIPSIS); + truncated +} + +/// A fully planned push request, minus the host-injected VAPID header. +#[derive(Debug, Clone)] +pub struct WebPushRequestPlan { + /// Push service host (already allowlist-validated at enrollment). + pub host: String, + /// Origin-form path + query of the subscription endpoint. + pub path_and_query: String, + /// Protocol headers: TTL, Urgency, Content-Encoding, Content-Type. + pub headers: Vec<(&'static str, String)>, + /// The complete `aes128gcm` encrypted body. + pub body: Vec, +} + +/// Encrypt `payload` for `subscription` and plan the POST. +pub fn build_push_request( + subscription: &PushSubscriptionRecord, + payload: &WebPushNotificationPayload, + ttl_seconds: u32, + urgency: PushUrgency, +) -> Result { + let plaintext = payload.to_json_bytes()?; + let ua_public = subscription.keys.p256dh_bytes()?; + let auth = subscription.keys.auth_bytes()?; + let body = crypto::encrypt_payload(&ua_public, &auth, &plaintext)?; + Ok(WebPushRequestPlan { + host: subscription.endpoint.host()?, + path_and_query: subscription.endpoint.path_and_query()?, + headers: vec![ + ("ttl", ttl_seconds.to_string()), + ("urgency", urgency.header_value().to_string()), + ("content-encoding", "aes128gcm".to_string()), + ("content-type", "application/octet-stream".to_string()), + ], + body, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::subscription::{PushEndpoint, PushSubscriptionKeys}; + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + + fn sample_subscription() -> PushSubscriptionRecord { + // A syntactically valid (but random) recipient: any 65-byte point + // with 0x04 prefix passes shape checks; encryption then requires it + // to be on-curve, so use a real generated key. + let private = aws_lc_rs::agreement::PrivateKey::generate(&aws_lc_rs::agreement::ECDH_P256) + .expect("generate"); + let public = private.compute_public_key().expect("public"); + PushSubscriptionRecord::new( + PushEndpoint::new("https://push.beta.example/wpush/v2/token?x=1").expect("endpoint"), + PushSubscriptionKeys::new( + URL_SAFE_NO_PAD.encode(public.as_ref()), + URL_SAFE_NO_PAD.encode([7u8; 16]), + ) + .expect("keys"), + Some("TestBrowser/1.0".to_string()), + "2026-08-08T00:00:00Z", + ) + } + + #[test] + fn payload_truncates_and_strips_controls() { + let payload = WebPushNotificationPayload::new( + "t\u{0007}itle".to_string(), + "b".repeat(5_000), + "/automations", + None, + ); + assert_eq!(payload.title, "t itle"); + assert!(payload.body.chars().count() <= MAX_BODY_CHARS); + assert!(payload.body.ends_with(ELLIPSIS)); + } + + #[test] + fn plan_carries_protocol_headers_and_origin_form_path() { + let subscription = sample_subscription(); + let payload = WebPushNotificationPayload::new( + "IronClaw", + "Automation finished", + "/automations", + None, + ); + let plan = build_push_request( + &subscription, + &payload, + DEFAULT_TTL_SECONDS, + PushUrgency::Normal, + ) + .expect("plan builds"); + assert_eq!(plan.host, "push.beta.example"); + assert_eq!(plan.path_and_query, "/wpush/v2/token?x=1"); + let header = |name: &str| { + plan.headers + .iter() + .find(|(header, _)| *header == name) + .map(|(_, value)| value.clone()) + }; + assert_eq!(header("ttl").as_deref(), Some("86400")); + assert_eq!(header("urgency").as_deref(), Some("normal")); + assert_eq!(header("content-encoding").as_deref(), Some("aes128gcm")); + assert!(plan.body.len() > 86, "encrypted body present"); + } +} diff --git a/crates/domains/ironclaw_web_push/src/runtime.rs b/crates/domains/ironclaw_web_push/src/runtime.rs new file mode 100644 index 00000000000..e4789dfbedd --- /dev/null +++ b/crates/domains/ironclaw_web_push/src/runtime.rs @@ -0,0 +1,113 @@ +//! Late-bound runtime state for the web-push channel. +//! +//! The channel adapter is constructed by the binary's binding table before +//! composition has built storage, so the adapter holds this slot and +//! composition installs the runtime exactly once at assembly (the same boot +//! ordering the trigger poller's buffered post-submit hook resolves). Until +//! installed, consumers fail closed with `WebPushError::RuntimeUnavailable`. + +use std::sync::{Arc, RwLock}; + +use crate::error::WebPushError; +use crate::store::WebPushSubscriptionStore; + +/// Everything the delivery adapter needs at send time. +pub struct WebPushRuntime { + pub subscriptions: Arc, +} + +/// Cloneable installer/consumer handle around the runtime. +#[derive(Clone, Default)] +pub struct WebPushRuntimeSlot { + inner: Arc>>>, +} + +impl WebPushRuntimeSlot { + pub fn new() -> Self { + Self::default() + } + + /// Install the runtime. Exactly once per process; a second install is a + /// wiring bug and fails loudly. + pub fn install(&self, runtime: Arc) -> Result<(), WebPushError> { + let mut guard = self + .inner + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.is_some() { + return Err(WebPushError::RuntimeAlreadyInstalled); + } + *guard = Some(runtime); + Ok(()) + } + + pub fn get(&self) -> Result, WebPushError> { + self.inner + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + .ok_or(WebPushError::RuntimeUnavailable) + } + + pub fn is_installed(&self) -> bool { + self.inner + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::{PushSubscriptionUpsertOutcome, WebPushSubscriptionStore}; + use crate::subscription::{PushEndpoint, PushSubscriptionRecord}; + use async_trait::async_trait; + use ironclaw_host_api::resource::ResourceScope; + + struct NullStore; + + #[async_trait] + impl WebPushSubscriptionStore for NullStore { + async fn upsert_subscription( + &self, + _scope: &ResourceScope, + _record: PushSubscriptionRecord, + ) -> Result { + Ok(PushSubscriptionUpsertOutcome::Enrolled) + } + + async fn remove_subscription( + &self, + _scope: &ResourceScope, + _endpoint: &PushEndpoint, + ) -> Result { + Ok(false) + } + + async fn list_subscriptions( + &self, + _scope: &ResourceScope, + ) -> Result, WebPushError> { + Ok(Vec::new()) + } + } + + #[test] + fn slot_fails_closed_then_installs_exactly_once() { + let slot = WebPushRuntimeSlot::new(); + assert!(matches!(slot.get(), Err(WebPushError::RuntimeUnavailable))); + assert!(!slot.is_installed()); + + let runtime = Arc::new(WebPushRuntime { + subscriptions: Arc::new(NullStore), + }); + slot.install(Arc::clone(&runtime)).expect("first install"); + assert!(slot.is_installed()); + assert!(slot.get().is_ok()); + assert!(matches!( + slot.install(runtime), + Err(WebPushError::RuntimeAlreadyInstalled) + )); + } +} diff --git a/crates/domains/ironclaw_web_push/src/store.rs b/crates/domains/ironclaw_web_push/src/store.rs new file mode 100644 index 00000000000..572bbf0bea6 --- /dev/null +++ b/crates/domains/ironclaw_web_push/src/store.rs @@ -0,0 +1,396 @@ +//! Durable push-subscription storage: one JSON document per (tenant, user) +//! on the scoped filesystem plane, mutated exclusively through the shared +//! bounded CAS path (`.claude/rules/database.md`). Backend selection stays +//! with composition; this store never branches on a backend. + +use std::sync::Arc; + +use async_trait::async_trait; +use ironclaw_filesystem::{ + CasApply, CasUpdateError, ContentType, Entry, RootFilesystem, ScopedFilesystem, cas_update, +}; +use ironclaw_host_api::path::ScopedPath; +use ironclaw_host_api::resource::ResourceScope; +use serde::{Deserialize, Serialize}; + +use crate::error::WebPushError; +use crate::subscription::{MAX_SUBSCRIPTIONS_PER_USER, PushEndpoint, PushSubscriptionRecord}; + +/// One document per (tenant, user): the scoped-filesystem mount view already +/// prefixes `/tenants//users/`, so the alias-relative path is +/// constant. Composition grants the `/web-push` alias in the per-user mount +/// view. +const SUBSCRIPTIONS_DOCUMENT: &str = "/web-push/subscriptions.json"; +const DOCUMENT_SCHEMA_VERSION: u32 = 1; + +/// Outcome of an upsert: whether the endpoint enrolled fresh or refreshed an +/// existing enrollment (same endpoint, e.g. rotated keys). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PushSubscriptionUpsertOutcome { + Enrolled, + Refreshed, +} + +/// Typed store contract for push subscriptions. +#[async_trait] +pub trait WebPushSubscriptionStore: Send + Sync { + /// Insert or refresh (by endpoint) one browser enrollment. + async fn upsert_subscription( + &self, + scope: &ResourceScope, + record: PushSubscriptionRecord, + ) -> Result; + + /// Remove one enrollment by endpoint. Returns whether it existed. + async fn remove_subscription( + &self, + scope: &ResourceScope, + endpoint: &PushEndpoint, + ) -> Result; + + /// All current enrollments for the scope's user, newest first. + async fn list_subscriptions( + &self, + scope: &ResourceScope, + ) -> Result, WebPushError>; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct SubscriptionDocument { + schema_version: u32, + tenant_id: String, + user_id: String, + subscriptions: Vec, +} + +impl SubscriptionDocument { + fn empty(scope: &ResourceScope) -> Self { + Self { + schema_version: DOCUMENT_SCHEMA_VERSION, + tenant_id: scope.tenant_id.to_string(), + user_id: scope.user_id.to_string(), + subscriptions: Vec::new(), + } + } + + /// Defense in depth beyond path scoping: a document whose recorded owner + /// disagrees with the requesting scope is corrupt or misrouted; refuse + /// rather than serve another user's enrollment set. + fn validate_owner(&self, scope: &ResourceScope) -> Result<(), WebPushError> { + if self.tenant_id != scope.tenant_id.to_string() + || self.user_id != scope.user_id.to_string() + { + return Err(WebPushError::InvalidScope { + reason: "stored subscription document does not belong to the requested scope" + .to_string(), + }); + } + Ok(()) + } +} + +/// Filesystem-plane implementation over a [`ScopedFilesystem`]. +pub struct FilesystemWebPushSubscriptionStore +where + F: RootFilesystem + ?Sized, +{ + filesystem: Arc>, +} + +impl FilesystemWebPushSubscriptionStore +where + F: RootFilesystem + ?Sized, +{ + pub fn new(filesystem: Arc>) -> Self { + Self { filesystem } + } +} + +fn document_path() -> Result { + ScopedPath::new(SUBSCRIPTIONS_DOCUMENT).map_err(|error| WebPushError::Store { + reason: format!("subscription path rejected: {error}"), + }) +} + +fn decode_document(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|error| WebPushError::Store { + reason: format!("subscription document decode failed: {error}"), + }) +} + +fn encode_document(document: &SubscriptionDocument) -> Result { + let bytes = serde_json::to_vec(document).map_err(|error| WebPushError::Store { + reason: format!("subscription document encode failed: {error}"), + })?; + Ok(Entry::bytes(bytes).with_content_type(ContentType::json())) +} + +fn map_cas_error(error: CasUpdateError) -> WebPushError { + match error { + CasUpdateError::Apply(inner) => inner, + other => WebPushError::Store { + reason: format!("subscription document update failed: {other}"), + }, + } +} + +#[async_trait] +impl WebPushSubscriptionStore for FilesystemWebPushSubscriptionStore +where + F: RootFilesystem + ?Sized, +{ + async fn upsert_subscription( + &self, + scope: &ResourceScope, + record: PushSubscriptionRecord, + ) -> Result { + let path = document_path()?; + cas_update( + self.filesystem.as_ref(), + scope, + &path, + decode_document, + encode_document, + move |current: Option| { + let record = record.clone(); + let scope = scope.clone(); + async move { + let mut document = + current.unwrap_or_else(|| SubscriptionDocument::empty(&scope)); + document.validate_owner(&scope)?; + if let Some(existing) = document + .subscriptions + .iter_mut() + .find(|existing| existing.endpoint == record.endpoint) + { + if existing.keys == record.keys && existing.user_agent == record.user_agent + { + return Ok(CasApply::no_op( + document, + PushSubscriptionUpsertOutcome::Refreshed, + )); + } + existing.keys = record.keys.clone(); + existing.user_agent = record.user_agent.clone(); + return Ok(CasApply::new( + document, + PushSubscriptionUpsertOutcome::Refreshed, + )); + } + if document.subscriptions.len() >= MAX_SUBSCRIPTIONS_PER_USER { + return Err(WebPushError::SubscriptionLimitReached { + limit: MAX_SUBSCRIPTIONS_PER_USER, + }); + } + // Newest first so the settings UI lists recent browsers on top. + document.subscriptions.insert(0, record); + Ok(CasApply::new( + document, + PushSubscriptionUpsertOutcome::Enrolled, + )) + } + }, + ) + .await + .map_err(map_cas_error) + } + + async fn remove_subscription( + &self, + scope: &ResourceScope, + endpoint: &PushEndpoint, + ) -> Result { + let path = document_path()?; + cas_update( + self.filesystem.as_ref(), + scope, + &path, + decode_document, + encode_document, + move |current: Option| { + let endpoint = endpoint.clone(); + let scope = scope.clone(); + async move { + let Some(mut document) = current else { + return Ok(CasApply::no_op(SubscriptionDocument::empty(&scope), false)); + }; + document.validate_owner(&scope)?; + let before = document.subscriptions.len(); + document + .subscriptions + .retain(|existing| existing.endpoint != endpoint); + if document.subscriptions.len() == before { + return Ok(CasApply::no_op(document, false)); + } + Ok(CasApply::new(document, true)) + } + }, + ) + .await + .map_err(map_cas_error) + } + + async fn list_subscriptions( + &self, + scope: &ResourceScope, + ) -> Result, WebPushError> { + let path = document_path()?; + let entry = self + .filesystem + .get(scope, &path) + .await + .map_err(WebPushError::store)?; + let Some(entry) = entry else { + return Ok(Vec::new()); + }; + let document = decode_document(&entry.entry.body)?; + document.validate_owner(scope)?; + Ok(document.subscriptions) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::subscription::PushSubscriptionKeys; + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use ironclaw_filesystem::InMemoryBackend; + use ironclaw_host_api::ids::{InvocationId, TenantId, UserId}; + + fn scope(user: &str) -> ResourceScope { + ResourceScope { + tenant_id: TenantId::new("tenant1").expect("tenant"), + user_id: UserId::new(user).expect("user"), + agent_id: None, + project_id: None, + mission_id: None, + thread_id: None, + invocation_id: InvocationId::new(), + } + } + + fn store() -> FilesystemWebPushSubscriptionStore { + FilesystemWebPushSubscriptionStore::new(Arc::new(ScopedFilesystem::new( + Arc::new(InMemoryBackend::new()), + |scope: &ResourceScope| { + use ironclaw_host_api::mount::{MountGrant, MountPermissions, MountView}; + use ironclaw_host_api::path::{MountAlias, VirtualPath}; + MountView::new(vec![MountGrant::new( + MountAlias::new("/web-push")?, + VirtualPath::new(format!( + "/tenants/{}/users/{}/web-push", + scope.tenant_id, scope.user_id + ))?, + MountPermissions::read_write_list_delete(), + )]) + }, + ))) + } + + fn keys(seed: u8) -> PushSubscriptionKeys { + let mut point = vec![0x04u8]; + point.extend_from_slice(&[seed; 64]); + PushSubscriptionKeys::new( + URL_SAFE_NO_PAD.encode(point), + URL_SAFE_NO_PAD.encode([seed; 16]), + ) + .expect("keys") + } + + fn record(token: &str, seed: u8) -> PushSubscriptionRecord { + PushSubscriptionRecord::new( + PushEndpoint::new(format!("https://push.alpha.example/send/{token}")) + .expect("endpoint"), + keys(seed), + Some("Browser".to_string()), + "2026-08-08T00:00:00Z", + ) + } + + #[tokio::test] + async fn enroll_refresh_list_and_remove_round_trip() { + let store = store(); + let scope = scope("user1"); + + let outcome = store + .upsert_subscription(&scope, record("alpha", 1)) + .await + .expect("enroll"); + assert_eq!(outcome, PushSubscriptionUpsertOutcome::Enrolled); + + // Same endpoint, rotated keys → refresh, not duplicate. + let outcome = store + .upsert_subscription(&scope, record("alpha", 2)) + .await + .expect("refresh"); + assert_eq!(outcome, PushSubscriptionUpsertOutcome::Refreshed); + + store + .upsert_subscription(&scope, record("beta", 3)) + .await + .expect("second browser"); + + let listed = store.list_subscriptions(&scope).await.expect("list"); + assert_eq!(listed.len(), 2); + assert!( + listed[0].endpoint.as_str().ends_with("beta"), + "newest first" + ); + + let removed = store + .remove_subscription(&scope, &listed[1].endpoint) + .await + .expect("remove"); + assert!(removed); + let listed = store.list_subscriptions(&scope).await.expect("list"); + assert_eq!(listed.len(), 1); + + let removed_again = store + .remove_subscription( + &scope, + &PushEndpoint::new("https://push.alpha.example/send/alpha").expect("endpoint"), + ) + .await + .expect("remove absent"); + assert!(!removed_again); + } + + #[tokio::test] + async fn scopes_are_isolated_per_user() { + let store = store(); + let first = scope("user1"); + let second = scope("user2"); + store + .upsert_subscription(&first, record("alpha", 1)) + .await + .expect("enroll"); + assert!( + store + .list_subscriptions(&second) + .await + .expect("list") + .is_empty(), + "another user's scope must not see the enrollment" + ); + } + + #[tokio::test] + async fn the_per_user_cap_fails_closed() { + let store = store(); + let scope = scope("user1"); + for index in 0..MAX_SUBSCRIPTIONS_PER_USER { + store + .upsert_subscription(&scope, record(&format!("t{index}"), index as u8)) + .await + .expect("enroll under cap"); + } + let over_cap = store + .upsert_subscription(&scope, record("overflow", 99)) + .await; + assert!(matches!( + over_cap, + Err(WebPushError::SubscriptionLimitReached { .. }) + )); + } +} diff --git a/crates/domains/ironclaw_web_push/src/subscription.rs b/crates/domains/ironclaw_web_push/src/subscription.rs new file mode 100644 index 00000000000..ea2ea09e4be --- /dev/null +++ b/crates/domains/ironclaw_web_push/src/subscription.rs @@ -0,0 +1,355 @@ +//! Push subscription record grammar and validation. +//! +//! A subscription is what the browser's `PushManager.subscribe()` returns: +//! an endpoint capability URL on a push service plus the client's P-256 +//! public key (`p256dh`) and 16-byte auth secret (`auth`). The endpoint URL +//! is a bearer capability — anyone holding it can attempt (unreadable) +//! deliveries — so records never render it into errors or logs; only the +//! host is ever surfaced. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::{Deserialize, Serialize}; + +use crate::error::WebPushError; + +/// Longest accepted endpoint URL. Real push-service endpoints run ~150-300 +/// bytes; 1 KiB leaves headroom without accepting unbounded input. +pub const MAX_ENDPOINT_BYTES: usize = 1024; + +/// Browsers enrolled per user. A user realistically holds a handful of +/// browser profiles; the cap bounds fan-out and storage. +pub const MAX_SUBSCRIPTIONS_PER_USER: usize = 16; + +const MAX_USER_AGENT_BYTES: usize = 256; +/// Uncompressed P-256 point: 0x04 || x || y. +const P256_UNCOMPRESSED_POINT_LEN: usize = 65; +const AUTH_SECRET_LEN: usize = 16; + +/// A validated push service endpoint URL. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String")] +pub struct PushEndpoint(String); + +impl PushEndpoint { + pub fn new(raw: impl Into) -> Result { + let raw = raw.into(); + Self::validate(&raw)?; + Ok(Self(raw)) + } + + fn validate(raw: &str) -> Result<(), WebPushError> { + if raw.len() > MAX_ENDPOINT_BYTES { + return Err(WebPushError::InvalidSubscription { + reason: format!("endpoint exceeds {MAX_ENDPOINT_BYTES} bytes"), + }); + } + let parsed = url::Url::parse(raw).map_err(|_| WebPushError::InvalidSubscription { + reason: "endpoint is not a valid URL".to_string(), + })?; + if parsed.scheme() != "https" { + return Err(WebPushError::InvalidSubscription { + reason: "endpoint must use https".to_string(), + }); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(WebPushError::InvalidSubscription { + reason: "endpoint must not carry userinfo".to_string(), + }); + } + if parsed.fragment().is_some() { + return Err(WebPushError::InvalidSubscription { + reason: "endpoint must not carry a fragment".to_string(), + }); + } + if parsed.port().is_some() { + return Err(WebPushError::InvalidSubscription { + reason: "endpoint must use the default https port".to_string(), + }); + } + if parsed.host_str().is_none() { + return Err(WebPushError::InvalidSubscription { + reason: "endpoint has no host".to_string(), + }); + } + Ok(()) + } + + /// Enrollment-time gate: the endpoint's push-service host must be one of + /// the deployment-declared hosts (the `[[channel.egress]]` entries of the + /// web-push manifest, resolved at composition — one source of truth, the + /// same list restricted egress enforces at send time). Shape validation + /// happens at construction so stored records rehydrate without the list; + /// this check runs only where a new enrollment is accepted. + pub fn validate_against_push_services( + &self, + allowed_hosts: &[String], + ) -> Result<(), WebPushError> { + let host = self.host()?; + if !allowed_hosts + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(&host)) + { + return Err(WebPushError::UnsupportedPushService { host }); + } + Ok(()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Lowercased push service host (validated at construction). + pub fn host(&self) -> Result { + let parsed = url::Url::parse(&self.0).map_err(|_| WebPushError::InvalidSubscription { + reason: "endpoint is not a valid URL".to_string(), + })?; + parsed + .host_str() + .map(str::to_ascii_lowercase) + .ok_or_else(|| WebPushError::InvalidSubscription { + reason: "endpoint has no host".to_string(), + }) + } + + /// Origin-form path + query for the restricted egress request. + pub fn path_and_query(&self) -> Result { + let parsed = url::Url::parse(&self.0).map_err(|_| WebPushError::InvalidSubscription { + reason: "endpoint is not a valid URL".to_string(), + })?; + let mut path = parsed.path().to_string(); + if let Some(query) = parsed.query() { + path.push('?'); + path.push_str(query); + } + Ok(path) + } +} + +impl TryFrom for PushEndpoint { + type Error = WebPushError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +/// Client keys from `PushSubscription.getKey()` — base64url (unpadded). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "UncheckedPushSubscriptionKeys")] +pub struct PushSubscriptionKeys { + /// Uncompressed P-256 public key (65 bytes decoded), base64url. + pub p256dh: String, + /// 16-byte auth secret, base64url. + pub auth: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct UncheckedPushSubscriptionKeys { + p256dh: String, + auth: String, +} + +impl PushSubscriptionKeys { + pub fn new(p256dh: impl Into, auth: impl Into) -> Result { + let p256dh = p256dh.into(); + let auth = auth.into(); + let p256dh_bytes = decode_b64url_field("p256dh", &p256dh)?; + if p256dh_bytes.len() != P256_UNCOMPRESSED_POINT_LEN || p256dh_bytes[0] != 0x04 { + return Err(WebPushError::InvalidSubscription { + reason: "p256dh must decode to a 65-byte uncompressed P-256 point".to_string(), + }); + } + let auth_bytes = decode_b64url_field("auth", &auth)?; + if auth_bytes.len() != AUTH_SECRET_LEN { + return Err(WebPushError::InvalidSubscription { + reason: "auth must decode to 16 bytes".to_string(), + }); + } + Ok(Self { p256dh, auth }) + } + + pub fn p256dh_bytes(&self) -> Result, WebPushError> { + decode_b64url_field("p256dh", &self.p256dh) + } + + pub fn auth_bytes(&self) -> Result, WebPushError> { + decode_b64url_field("auth", &self.auth) + } +} + +impl TryFrom for PushSubscriptionKeys { + type Error = WebPushError; + + fn try_from(value: UncheckedPushSubscriptionKeys) -> Result { + Self::new(value.p256dh, value.auth) + } +} + +fn decode_b64url_field(field: &str, value: &str) -> Result, WebPushError> { + URL_SAFE_NO_PAD + .decode(value.trim_end_matches('=')) + .map_err(|_| WebPushError::InvalidSubscription { + reason: format!("{field} is not valid base64url"), + }) +} + +/// One enrolled browser. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PushSubscriptionRecord { + /// Stable id for the enrollment (uuid v4 string). + pub subscription_id: String, + pub endpoint: PushEndpoint, + pub keys: PushSubscriptionKeys, + /// Bounded, control-stripped browser description for the settings UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_agent: Option, + /// RFC 3339 UTC instant the browser enrolled. + pub created_at: String, +} + +impl PushSubscriptionRecord { + pub fn new( + endpoint: PushEndpoint, + keys: PushSubscriptionKeys, + user_agent: Option, + created_at: impl Into, + ) -> Self { + Self { + subscription_id: uuid::Uuid::new_v4().to_string(), + endpoint, + keys, + user_agent: user_agent.map(sanitize_user_agent), + created_at: created_at.into(), + } + } +} + +fn sanitize_user_agent(raw: String) -> String { + let cleaned: String = raw + .chars() + .filter(|character| !character.is_control()) + .collect(); + if cleaned.len() <= MAX_USER_AGENT_BYTES { + return cleaned; + } + let mut cut = MAX_USER_AGENT_BYTES; + while cut > 0 && !cleaned.is_char_boundary(cut) { + cut -= 1; + } + cleaned[..cut].to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + pub(crate) fn sample_keys() -> PushSubscriptionKeys { + // 65-byte uncompressed point (0x04 then 64 bytes) and 16-byte auth. + let mut point = vec![0x04u8]; + point.extend_from_slice(&[0x11u8; 64]); + PushSubscriptionKeys::new( + URL_SAFE_NO_PAD.encode(point), + URL_SAFE_NO_PAD.encode([0x22u8; 16]), + ) + .expect("sample keys are valid") + } + + #[test] + fn endpoint_allowlist_gate_admits_declared_hosts_only() { + let allowed = vec![ + "push.alpha.example".to_string(), + "push.beta.example".to_string(), + ]; + let enrolled = PushEndpoint::new("https://push.alpha.example/send/abc123") + .expect("well-formed endpoint validates by shape"); + enrolled + .validate_against_push_services(&allowed) + .expect("declared host is admitted"); + let foreign = PushEndpoint::new("https://evil.example.com/send/abc") + .expect("shape validation alone admits any https host"); + assert!(matches!( + foreign.validate_against_push_services(&allowed), + Err(WebPushError::UnsupportedPushService { host }) if host == "evil.example.com" + )); + assert!( + matches!( + enrolled.validate_against_push_services(&[]), + Err(WebPushError::UnsupportedPushService { .. }) + ), + "an empty declared set fails closed" + ); + } + + #[test] + fn endpoint_rejects_unsafe_shapes() { + for raw in [ + "http://push.alpha.example/send/abc", + "https://user:pw@push.alpha.example/send/abc", + "https://push.alpha.example/send/abc#frag", + "https://push.alpha.example:8443/send/abc", + "not a url", + ] { + assert!(PushEndpoint::new(raw).is_err(), "{raw:?} must be rejected"); + } + let long = format!( + "https://push.alpha.example/send/{}", + "a".repeat(MAX_ENDPOINT_BYTES) + ); + assert!(PushEndpoint::new(long).is_err()); + } + + #[test] + fn endpoint_host_comparison_is_case_insensitive() { + let endpoint = PushEndpoint::new("https://PUSH.Alpha.EXAMPLE/send/abc") + .expect("host case folds at the boundary"); + assert_eq!(endpoint.host().expect("host"), "push.alpha.example"); + endpoint + .validate_against_push_services(&["push.ALPHA.example".to_string()]) + .expect("allowlist comparison is case-insensitive"); + } + + #[test] + fn keys_validate_decoded_shapes() { + assert!(PushSubscriptionKeys::new("!!!", "AAAAAAAAAAAAAAAAAAAAAA").is_err()); + // Wrong point length. + assert!( + PushSubscriptionKeys::new(URL_SAFE_NO_PAD.encode([0x04; 10]), "AAAAAAAAAAAAAAAAAAAAAA") + .is_err() + ); + // Wrong auth length. + let mut point = vec![0x04u8]; + point.extend_from_slice(&[0u8; 64]); + assert!( + PushSubscriptionKeys::new( + URL_SAFE_NO_PAD.encode(&point), + URL_SAFE_NO_PAD.encode([0u8; 5]) + ) + .is_err() + ); + sample_keys(); + } + + #[test] + fn path_and_query_is_origin_form() { + let endpoint = + PushEndpoint::new("https://push.alpha.example/fcm/send/abc?x=1").expect("valid"); + assert_eq!( + endpoint.path_and_query().expect("path"), + "/fcm/send/abc?x=1" + ); + } + + #[test] + fn user_agent_is_bounded_and_control_stripped() { + let record = PushSubscriptionRecord::new( + PushEndpoint::new("https://push.alpha.example/send/a").expect("valid"), + sample_keys(), + Some(format!("bad\r\nagent {}", "x".repeat(400))), + "2026-08-08T00:00:00Z", + ); + let agent = record.user_agent.expect("agent kept"); + assert!(!agent.contains('\n')); + assert!(agent.len() <= MAX_USER_AGENT_BYTES); + } +} diff --git a/crates/domains/ironclaw_web_push/src/vapid.rs b/crates/domains/ironclaw_web_push/src/vapid.rs new file mode 100644 index 00000000000..b455dbf891a --- /dev/null +++ b/crates/domains/ironclaw_web_push/src/vapid.rs @@ -0,0 +1,127 @@ +//! VAPID (RFC 8292) key material generation. +//! +//! Generation happens once per deployment (composition seeds it into the +//! channel's credential storage); the *signing* of per-request +//! `Authorization: vapid` headers happens at the host egress credential +//! boundary (`ironclaw_host_runtime`), which parses the same +//! [`VapidCredentialMaterialV1`] schema. This module never signs and never +//! reads stored material back. + +use aws_lc_rs::signature::KeyPair as _; +use aws_lc_rs::{rand as aws_rand, signature}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ironclaw_host_api::http::VapidCredentialMaterialV1; + +use crate::error::WebPushError; + +/// Freshly generated VAPID material: the JSON blob to store as the channel +/// credential, plus the (non-secret) public key the browser needs as +/// `applicationServerKey`. +#[derive(Debug, Clone)] +pub struct GeneratedVapidKeyMaterial { + /// Serialized [`VapidCredentialMaterialV1`] — store as the channel + /// credential under the web-push VAPID handle. Contains the private key. + pub material_json: String, + /// Base64url (unpadded) uncompressed P-256 public key (65 bytes). + pub public_key_b64url: String, +} + +/// Generate a fresh P-256 keypair and wrap it in the credential-material +/// schema the host egress VAPID injector consumes. +/// +/// `subject` is the RFC 8292 `sub` claim — a `mailto:` or `https:` URI +/// identifying the operator to push services. +pub fn generate_vapid_key_material( + subject: &str, +) -> Result { + validate_vapid_subject(subject)?; + let rng = aws_rand::SystemRandom::new(); + let document = + signature::EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng) + .map_err(|_| WebPushError::crypto("VAPID keypair generation failed"))?; + let key_pair = signature::EcdsaKeyPair::from_pkcs8( + &signature::ECDSA_P256_SHA256_FIXED_SIGNING, + document.as_ref(), + ) + .map_err(|_| WebPushError::crypto("generated VAPID keypair failed to parse"))?; + let public_key = key_pair.public_key().as_ref().to_vec(); + if public_key.len() != 65 || public_key[0] != 0x04 { + return Err(WebPushError::crypto( + "VAPID public key is not an uncompressed P-256 point", + )); + } + let public_key_b64url = URL_SAFE_NO_PAD.encode(&public_key); + let material = VapidCredentialMaterialV1 { + es256_private_key_pkcs8_b64url: URL_SAFE_NO_PAD.encode(document.as_ref()), + public_key_b64url: public_key_b64url.clone(), + subject: subject.to_string(), + }; + let material_json = serde_json::to_string(&material) + .map_err(|error| WebPushError::crypto(format!("VAPID material serialization: {error}")))?; + Ok(GeneratedVapidKeyMaterial { + material_json, + public_key_b64url, + }) +} + +/// RFC 8292 §2.1: the subject is a contact URI for the application server — +/// `mailto:` or `https:`. +pub fn validate_vapid_subject(subject: &str) -> Result<(), WebPushError> { + let valid = subject.starts_with("mailto:") && subject.len() > "mailto:".len() + || subject.starts_with("https://") && subject.len() > "https://".len(); + if !valid { + return Err(WebPushError::InvalidScope { + reason: "VAPID subject must be a mailto: or https: URI".to_string(), + }); + } + if subject.len() > 256 || subject.chars().any(char::is_control) { + return Err(WebPushError::InvalidScope { + reason: "VAPID subject must be a short control-free URI".to_string(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_material_parses_and_round_trips_public_key() { + let generated = + generate_vapid_key_material("mailto:ops@example.com").expect("generation succeeds"); + let material: VapidCredentialMaterialV1 = + serde_json::from_str(&generated.material_json).expect("material JSON parses"); + assert_eq!(material.public_key_b64url, generated.public_key_b64url); + assert_eq!(material.subject, "mailto:ops@example.com"); + + // The stored private key must re-parse and its public key must match. + let der = URL_SAFE_NO_PAD + .decode(&material.es256_private_key_pkcs8_b64url) + .expect("private key base64"); + let key_pair = + signature::EcdsaKeyPair::from_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &der) + .expect("private key parses"); + assert_eq!( + URL_SAFE_NO_PAD.encode(key_pair.public_key().as_ref()), + generated.public_key_b64url + ); + } + + #[test] + fn distinct_generations_produce_distinct_keys() { + let first = generate_vapid_key_material("mailto:a@example.com").expect("first"); + let second = generate_vapid_key_material("mailto:a@example.com").expect("second"); + assert_ne!(first.public_key_b64url, second.public_key_b64url); + } + + #[test] + fn subjects_are_validated() { + assert!(validate_vapid_subject("mailto:ops@example.com").is_ok()); + assert!(validate_vapid_subject("https://ironclaw.example").is_ok()); + for bad in ["", "mailto:", "https://", "http://x", "ops@example.com"] { + assert!(validate_vapid_subject(bad).is_err(), "{bad:?} must fail"); + } + } +} diff --git a/crates/extensions/AGENTS.md b/crates/extensions/AGENTS.md index b11bf07b1ec..75c1668ef29 100644 --- a/crates/extensions/AGENTS.md +++ b/crates/extensions/AGENTS.md @@ -1,6 +1,6 @@ # `crates/extensions/` — everything "installable package" -**Layer(s):** substrates (`ironclaw_extension_registry`, both memory providers) · runtimes (`ironclaw_extension_support`) · loops (`ironclaw_extension_host`) · products (`ironclaw_extension_manager`, both channel packages) · **Crates:** 8 (re-derive: `ls -d crates/extensions/*/` for the family crates, `ls -d crates/extensions/packages/*/` for the 14 packages, of which 4 carry crates) · **May depend on:** downward only, per crate — the registry reaches contracts + `ironclaw_filesystem`; the host reaches kernel, domains, and loop; packages reach contracts (+ the domain contract a provider implements) · **Depended on by:** the binary (`ironclaw_cli`) and `ironclaw_composition`; the registry additionally by kernel/lanes/loop/events crates that read manifest vocabulary; `ironclaw_webui` holds one sanctioned edge onto the host (pairing). +**Layer(s):** substrates (`ironclaw_extension_registry`, both memory providers) · runtimes (`ironclaw_extension_support`) · loops (`ironclaw_extension_host`) · products (`ironclaw_extension_manager`, both channel packages) · **Crates:** 9 (re-derive: `ls -d crates/extensions/*/` for the family crates, `ls -d crates/extensions/packages/*/` for the 14 packages, of which 4 carry crates) · **May depend on:** downward only, per crate — the registry reaches contracts + `ironclaw_filesystem`; the host reaches kernel, domains, and loop; packages reach contracts (+ the domain contract a provider implements) · **Depended on by:** the binary (`ironclaw_cli`) and `ironclaw_composition`; the registry additionally by kernel/lanes/loop/events crates that read manifest vocabulary; `ironclaw_webui` holds one sanctioned edge onto the host (pairing). ## What this family is @@ -120,6 +120,7 @@ Editing `wasm-src/` without rebuilding and re-recording fails CI. | `notion-mcp/` | `notion` | `[mcp]` hosted server (tools discovered) | `notion` | mcp | data-only | | `slack/` | `slack` | 8 tools + channel | `slack` | wasm (tools) + first-party adapter | crate `ironclaw_slack_extension` + `wasm/` | | `telegram/` | `telegram` | channel only (no tools, no auth recipe — deployment credentials via `[admin_configuration]`) | — | first_party | crate `ironclaw_telegram_extension` | +| `web-push/` | `web-push` | channel only (outbound-only browser push; no ingress, no pairing — VAPID key material auto-seeded under `[admin_configuration]`) | — | first_party | crate `ironclaw_web_push_extension` | | `web-access/` | `web-access` | 2 tools | — | first_party | data-only (executor: `extension_support::web_access`) | Data-only packages ship through the `PACKAGES` inventory in diff --git a/crates/extensions/ironclaw_extension_host/src/deployment_channels.rs b/crates/extensions/ironclaw_extension_host/src/deployment_channels.rs index 31c756e987a..e136cc26fa1 100644 --- a/crates/extensions/ironclaw_extension_host/src/deployment_channels.rs +++ b/crates/extensions/ironclaw_extension_host/src/deployment_channels.rs @@ -28,7 +28,15 @@ impl DeploymentChannelBinding { let Some(channel) = resolved.channel.as_ref() else { return Err(DeploymentChannelRegistryError::MissingChannel { extension_id }); }; - if !channel.inbound || channel.ingress.is_none() { + // An inbound channel is only mountable with its ingress declared. An + // outbound-only channel (web push) is a legitimate deployment binding + // with nothing to mount — delivery resolution still needs its adapter + // + egress declarations, and `resolve_channel_ingress` already guards + // the inbound/ingress pair at lookup. + if channel.inbound && channel.ingress.is_none() { + return Err(DeploymentChannelRegistryError::MissingInboundIngress { extension_id }); + } + if !channel.inbound && !channel.outbound { return Err(DeploymentChannelRegistryError::MissingInboundIngress { extension_id }); } Ok(Self { @@ -121,6 +129,28 @@ mod tests { ); } + #[test] + fn outbound_only_channel_binds_without_ingress_and_never_resolves_ingress() { + let manifest = Arc::new(crate::test_support::outbound_only_channel_manifest()); + let registry = DeploymentChannelRegistry::try_new([DeploymentChannelBinding::new( + Arc::clone(&manifest), + Arc::new(crate::test_support::FakeChannelAdapter::default()), + ) + .expect("an outbound-only channel is a legitimate deployment binding")]) + .expect("deployment registry validates"); + + let binding = registry + .extension("acme-push") + .expect("outbound-only binding resolves for delivery"); + assert_eq!(binding.resolved.id, manifest.id); + assert!( + registry + .resolve_channel_ingress("acme-push", "events") + .is_none(), + "an outbound-only channel mounts no ingress route" + ); + } + #[test] fn duplicate_extension_bindings_fail_closed() { let manifest = Arc::new(crate::test_support::channel_only_manifest()); diff --git a/crates/extensions/ironclaw_extension_host/src/test_support.rs b/crates/extensions/ironclaw_extension_host/src/test_support.rs index aa422c507c4..5e2b3c3a7ee 100644 --- a/crates/extensions/ironclaw_extension_host/src/test_support.rs +++ b/crates/extensions/ironclaw_extension_host/src/test_support.rs @@ -92,6 +92,26 @@ client_credentials = { client_id_handle = "acme_tools_client_id" } access_token = "/access_token" "#; +const OUTBOUND_ONLY_CHANNEL_MANIFEST: &str = r#" +schema_version = "reborn.extension_manifest.v3" +id = "acme-push" +name = "Acme Push" +version = "0.1.0" +description = "fixture: outbound-only channel extension" +trust = "third_party" + +[runtime] +kind = "wasm" +module = "wasm/acme_push.wasm" + +[channel] +id = "notifications" +display_name = "Acme push" +inbound = false +outbound = true +conversation_model = "continuous" +"#; + const CHANNEL_MANIFEST: &str = r#" schema_version = "reborn.extension_manifest.v3" id = "acme-chat" @@ -246,6 +266,12 @@ pub fn channel_only_manifest() -> ResolvedExtensionManifest { resolve(CHANNEL_MANIFEST) } +/// An outbound-only channel manifest (no ingress section) — the web-push +/// deployment shape: nothing to mount, everything to deliver. +pub fn outbound_only_channel_manifest() -> ResolvedExtensionManifest { + resolve(OUTBOUND_ONLY_CHANNEL_MANIFEST) +} + /// A tool + channel + auth resolved manifest. pub fn tool_and_channel_manifest() -> ResolvedExtensionManifest { resolve(TOOL_AND_CHANNEL_MANIFEST) diff --git a/crates/extensions/packages/web-push/Cargo.toml b/crates/extensions/packages/web-push/Cargo.toml new file mode 100644 index 00000000000..725eccbff14 --- /dev/null +++ b/crates/extensions/packages/web-push/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "ironclaw_web_push_extension" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +description = "Web Push channel package: browser-notification delivery for the web app (adapter, preference-target codec, outbound target provider)" +publish = false + +[package.metadata.ironclaw] +layer = "products" + +[dependencies] +async-trait = "0.1" +ironclaw_extension_contracts = { path = "../../../contracts/ironclaw_extension_contracts" } +ironclaw_host_api = { path = "../../../contracts/ironclaw_host_api" } +# The channel's vendor-side state — the per-user browser subscription +# registry — is host-local by nature (the analogue of a chat vendor's +# workspace lives in our own database), so this package reaches its domain +# crate the same way the memory provider packages reach theirs. +ironclaw_outbound = { path = "../../../domains/ironclaw_outbound" } +ironclaw_web_push = { path = "../../../domains/ironclaw_web_push" } +tracing = "0.1" + +[dev-dependencies] +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } +toml = "0.9" diff --git a/crates/extensions/packages/web-push/README.md b/crates/extensions/packages/web-push/README.md new file mode 100644 index 00000000000..e2564fe1375 --- /dev/null +++ b/crates/extensions/packages/web-push/README.md @@ -0,0 +1,31 @@ +# web-push — Browser notifications + +The web app's browser-notification channel: outbound-only Web Push +(RFC 8030/8291/8292) to the user's enrolled browsers. + +- **Extension id:** `web-push` · **Surfaces:** channel only (outbound-only — + no ingress, no tools, no auth recipe) · **Runtime:** first_party · + **Code:** crate `ironclaw_web_push_extension` +- **Deployment-bound** like Telegram: the binary's binding table links the + adapter and codec; there is no pairing flow (browser enrollment happens in + the authenticated WebUI session via the web-push product commands). +- **Credentials:** the `web_push_vapid` handle holds auto-generated VAPID key + material (`VapidCredentialMaterialV1`), seeded by composition at boot — + never operator-typed. The RFC 8292 `Authorization: vapid` header is + computed host-side by the `vapid_authorization` egress injection; the + adapter never sees key bytes. +- **State:** per-user subscription records live in the + `ironclaw_web_push` domain crate (this package's "vendor side" is our own + database). 404/410 responses prune the dead subscription. +- **Evidence:** push services acknowledge acceptance (2xx) without a + readable message reference, so delivery reports `Sent` with no vendor ref — + acceptance by the push service, not device receipt. + +The egress host allowlist in `manifest.toml` must stay in lockstep with +`ironclaw_web_push::SUPPORTED_PUSH_SERVICE_HOSTS` +(`tests/manifest_lockstep.rs` pins it). + +## Validation + +- `cargo test -p ironclaw_web_push_extension` +- `cargo clippy -p ironclaw_web_push_extension --all-targets --all-features -- -D warnings` diff --git a/crates/extensions/packages/web-push/manifest.toml b/crates/extensions/packages/web-push/manifest.toml new file mode 100644 index 00000000000..1bb90ebdb9a --- /dev/null +++ b/crates/extensions/packages/web-push/manifest.toml @@ -0,0 +1,78 @@ +# The web app's browser-notification channel: outbound-only Web Push +# (RFC 8030/8291/8292) to the user's enrolled browsers. Deployment-bound like +# Telegram — no pairing and no inbound surface; enrollment happens in the +# authenticated WebUI session, and the VAPID key material is generated and +# seeded by the host at boot (never typed by an operator). +schema_version = "reborn.extension_manifest.v3" +id = "web-push" +name = "Browser notifications" +version = "0.1.0" +description = "Web Push notifications to the web app's enrolled browsers" +trust = "first_party_requested" + +[runtime] +kind = "first_party" +service = "web-push.extension/v1" + +[admin_configuration] +group_id = "extension.web-push" +display_name = "Web push deployment configuration" +description = "Auto-generated VAPID application-server key material for browser push." +fields = [ + # Seeded programmatically at boot when absent; `required = false` so the + # operator config surface never demands manual input. + { handle = "web_push_vapid", label = "VAPID key material (auto-generated)", secret = true, required = false }, +] + +# ---- channel surface ------------------------------------------------------- + +[channel] +id = "notifications" +display_name = "Browser notifications" +inbound = false +outbound = true +conversation_model = "continuous" + +# Exact push-service hosts, in lockstep with +# `ironclaw_web_push::SUPPORTED_PUSH_SERVICE_HOSTS` (pinned by +# `tests/manifest_lockstep.rs`). Endpoint paths are opaque per-subscription +# capability tokens, so the path constraint is the whole-host prefix; the +# host-level allowlist plus subscribe-time endpoint validation are the +# security boundary. `vapid_authorization` injection computes the RFC 8292 +# `Authorization: vapid` header host-side from the seeded key material — +# the adapter never sees key bytes. + +[[channel.egress]] +scheme = "https" +host = "fcm.googleapis.com" +methods = ["post"] +credential_handle = "web_push_vapid" +path_prefixes = ["/"] +request_body_limit_bytes = 8192 +response_body_limit_bytes = 65536 +injection = { type = "vapid_authorization" } + +[[channel.egress]] +scheme = "https" +host = "updates.push.services.mozilla.com" +methods = ["post"] +credential_handle = "web_push_vapid" +path_prefixes = ["/"] +request_body_limit_bytes = 8192 +response_body_limit_bytes = 65536 +injection = { type = "vapid_authorization" } + +[[channel.egress]] +scheme = "https" +host = "web.push.apple.com" +methods = ["post"] +credential_handle = "web_push_vapid" +path_prefixes = ["/"] +request_body_limit_bytes = 8192 +response_body_limit_bytes = 65536 +injection = { type = "vapid_authorization" } + +[channel.presentation] +supports_markdown = false +supports_threads = false +max_message_chars = 1500 diff --git a/crates/extensions/packages/web-push/src/channel.rs b/crates/extensions/packages/web-push/src/channel.rs new file mode 100644 index 00000000000..42100bdfcff --- /dev/null +++ b/crates/extensions/packages/web-push/src/channel.rs @@ -0,0 +1,289 @@ +//! The Web Push `ChannelAdapter`: render one envelope into one notification, +//! encrypt it per enrolled browser, and POST through restricted egress. + +use async_trait::async_trait; +use ironclaw_extension_contracts::auth_prompt::render_channel_auth_prompt; +use ironclaw_extension_contracts::channel_adapter::{ + ChannelAdapter, ChannelError, DeliveryReport, InboundOutcome, OutboundEnvelope, OutboundPart, + PartDeliveryOutcome, VerifiedInbound, +}; +use ironclaw_extension_contracts::tool_adapter::{ + RestrictedEgress, RestrictedEgressError, RestrictedEgressRequest, +}; +use ironclaw_host_api::action::NetworkMethod; +use ironclaw_host_api::ids::{InvocationId, SecretHandle}; +use ironclaw_host_api::resource::ResourceScope; +use ironclaw_web_push::{ + DEFAULT_TTL_SECONDS, PushSubscriptionRecord, PushUrgency, WEB_PUSH_VAPID_CREDENTIAL_HANDLE, + WebPushNotificationPayload, WebPushRuntimeSlot, build_push_request, decode_web_push_target_ref, +}; + +/// Deep link a notification opens; the service worker resolves it against +/// the app origin. +const NOTIFICATION_URL: &str = "/automations"; +const NOTIFICATION_TITLE: &str = "IronClaw"; + +/// Per-subscription send outcomes, folded into one part outcome. +#[derive(Default)] +struct FanOutTally { + accepted: usize, + pruned: usize, + retryable: Option, + permanent: Option, + unauthorized: Option, +} + +pub struct WebPushChannelAdapter { + runtime: WebPushRuntimeSlot, +} + +impl WebPushChannelAdapter { + pub fn new(runtime: WebPushRuntimeSlot) -> Self { + Self { runtime } + } +} + +#[async_trait] +impl ChannelAdapter for WebPushChannelAdapter { + /// Web Push has no inbound surface: enrollment happens in the + /// authenticated WebUI session, never through channel ingress. + fn inbound(&self, _request: VerifiedInbound<'_>) -> Result { + Err(ChannelError::Unsupported) + } + + async fn deliver( + &self, + envelope: OutboundEnvelope, + egress: &dyn RestrictedEgress, + ) -> Result { + let conversation_id = envelope.target.conversation.conversation_id(); + let Some((tenant_id, user_id)) = decode_web_push_target_ref(conversation_id) else { + return Err(ChannelError::Render { + reason: "delivery target is not a web-push conversation".to_string(), + }); + }; + let runtime = self + .runtime + .get() + .map_err(|_| ChannelError::Configuration { + reason: "web push runtime is not wired yet".to_string(), + })?; + let scope = ResourceScope { + tenant_id, + user_id, + agent_id: None, + project_id: None, + mission_id: None, + thread_id: None, + invocation_id: InvocationId::new(), + }; + let subscriptions = runtime + .subscriptions + .list_subscriptions(&scope) + .await + .map_err(|error| ChannelError::Configuration { + reason: format!("push subscription lookup failed: {error}"), + })?; + + // Render every deliverable part into one notification body; push is + // a coalesced surface, not a message stream. + let mut lines: Vec = Vec::new(); + let mut urgency = PushUrgency::Normal; + let mut part_supported: Vec> = Vec::new(); + for part in &envelope.parts { + match part { + OutboundPart::Text(text) => { + lines.push(text.clone()); + part_supported.push(Ok(())); + } + OutboundPart::AuthPrompt { + view, + direct_message, + } => { + lines.push(render_channel_auth_prompt(view, *direct_message)); + urgency = PushUrgency::High; + part_supported.push(Ok(())); + } + OutboundPart::File(_) => { + part_supported.push(Err("attachments are not supported by browser push")); + } + OutboundPart::Retract { .. } => { + part_supported.push(Err("retraction is not supported by browser push")); + } + } + } + + let has_deliverable = part_supported.iter().any(Result::is_ok); + if !has_deliverable { + return Ok(DeliveryReport { + parts: part_supported + .into_iter() + .map(|supported| match supported { + Ok(()) => PartDeliveryOutcome::Permanent { + reason: "nothing to deliver".to_string(), + }, + Err(reason) => PartDeliveryOutcome::Permanent { + reason: reason.to_string(), + }, + }) + .collect(), + }); + } + + let deliverable_outcome = if subscriptions.is_empty() { + PartDeliveryOutcome::Permanent { + reason: "no browsers are enrolled for web push".to_string(), + } + } else { + let payload = WebPushNotificationPayload::new( + NOTIFICATION_TITLE, + lines.join("\n\n"), + NOTIFICATION_URL, + None, + ); + let tally = self + .fan_out(&runtime, &scope, &subscriptions, &payload, urgency, egress) + .await?; + fold_tally(tally) + }; + + Ok(DeliveryReport { + parts: part_supported + .into_iter() + .map(|supported| match supported { + Ok(()) => deliverable_outcome.clone(), + Err(reason) => PartDeliveryOutcome::Permanent { + reason: reason.to_string(), + }, + }) + .collect(), + }) + } +} + +impl WebPushChannelAdapter { + async fn fan_out( + &self, + runtime: &ironclaw_web_push::WebPushRuntime, + scope: &ResourceScope, + subscriptions: &[PushSubscriptionRecord], + payload: &WebPushNotificationPayload, + urgency: PushUrgency, + egress: &dyn RestrictedEgress, + ) -> Result { + let credential_handle = + SecretHandle::new(WEB_PUSH_VAPID_CREDENTIAL_HANDLE).map_err(|_| { + ChannelError::Configuration { + reason: "VAPID credential handle is invalid".to_string(), + } + })?; + let mut tally = FanOutTally::default(); + for subscription in subscriptions { + let plan = match build_push_request(subscription, payload, DEFAULT_TTL_SECONDS, urgency) + { + Ok(plan) => plan, + Err(error) => { + tally.permanent = Some(format!("push request planning failed: {error}")); + continue; + } + }; + let request = RestrictedEgressRequest { + method: NetworkMethod::Post, + url: subscription.endpoint.as_str().to_string(), + headers: plan + .headers + .iter() + .map(|(name, value)| ((*name).to_string(), value.clone())) + .collect(), + body: Some(plan.body), + credential: Some(credential_handle.clone()), + body_credentials: Vec::new(), + }; + match egress.send(request).await { + Ok(response) => match response.status { + 200..=299 => tally.accepted += 1, + 404 | 410 => { + // The push service says this subscription no longer + // exists — prune it so future sends stop trying. + // Prune failure must not fail the delivery. + if let Err(error) = runtime + .subscriptions + .remove_subscription(scope, &subscription.endpoint) + .await + { + tracing::debug!( + target: "ironclaw::web_push", + error = %error, + "failed to prune an expired push subscription" + ); + } + tally.pruned += 1; + } + 401 | 403 => { + tally.unauthorized = Some(format!( + "push service rejected VAPID authorization (status {})", + response.status + )); + } + 413 => { + tally.permanent = + Some("push payload exceeds the push service limit".to_string()); + } + 429 => { + tally.retryable = Some("push service rate limited the send".to_string()); + } + 500..=599 => { + tally.retryable = Some(format!( + "push service unavailable (status {})", + response.status + )); + } + other => { + tally.permanent = Some(format!( + "push service rejected the request (status {other})" + )); + } + }, + Err(RestrictedEgressError::AuthRequired { .. }) => { + tally.unauthorized = Some("VAPID key material is not available".to_string()); + } + Err(error @ RestrictedEgressError::Transport { .. }) => { + tally.retryable = Some(error.to_string()); + } + Err(error) => { + tally.permanent = Some(error.to_string()); + } + } + } + Ok(tally) + } +} + +fn fold_tally(tally: FanOutTally) -> PartDeliveryOutcome { + if tally.accepted > 0 { + // Push services return 201/202 with no durable message reference the + // adapter is allowed to read (response headers are host-withheld), + // so the honest evidence is Sent-without-ref: acceptance by the push + // service, not device receipt. + return PartDeliveryOutcome::Sent { + vendor_message_ref: None, + }; + } + if let Some(reason) = tally.unauthorized { + return PartDeliveryOutcome::Unauthorized { reason }; + } + if let Some(reason) = tally.retryable { + return PartDeliveryOutcome::Retryable { reason }; + } + if let Some(reason) = tally.permanent { + return PartDeliveryOutcome::Permanent { reason }; + } + if tally.pruned > 0 { + return PartDeliveryOutcome::Permanent { + reason: "every enrolled browser subscription has expired".to_string(), + }; + } + PartDeliveryOutcome::Permanent { + reason: "no push delivery was attempted".to_string(), + } +} diff --git a/crates/extensions/packages/web-push/src/lib.rs b/crates/extensions/packages/web-push/src/lib.rs new file mode 100644 index 00000000000..66cca0e7596 --- /dev/null +++ b/crates/extensions/packages/web-push/src/lib.rs @@ -0,0 +1,22 @@ +//! Web Push channel package: the `ChannelAdapter` that fans one delivery out +//! to a user's enrolled browsers, the preference-target codec for the +//! `web-push/v1//` binding grammar, and the owner-scoped +//! outbound target provider that puts "Web app" in the delivery-target +//! catalog. +//! +//! Protocol mechanics (records, encryption, request planning) live in +//! `ironclaw_web_push`; the `Authorization: vapid` header is computed +//! host-side at the egress credential boundary. This crate is linked only by +//! the binary's binding table, like every concrete channel package. + +mod channel; +mod preference_targets; +mod targets; + +/// The package manifest, embedded crate-locally so the binary's bundle table +/// can ship it without a cross-crate include reach-in (§11.2.7). +pub const MANIFEST: &str = include_str!("../manifest.toml"); + +pub use channel::WebPushChannelAdapter; +pub use preference_targets::WebPushPreferenceTargetCodec; +pub use targets::{WEB_PUSH_TARGET_PROVIDER_KEY, WebPushOutboundTargetProvider}; diff --git a/crates/extensions/packages/web-push/src/preference_targets.rs b/crates/extensions/packages/web-push/src/preference_targets.rs new file mode 100644 index 00000000000..fde2cab673f --- /dev/null +++ b/crates/extensions/packages/web-push/src/preference_targets.rs @@ -0,0 +1,95 @@ +//! The web-push reply-target binding codec. +//! +//! Every web-push target is a personal direct-message surface by +//! construction: the binding names exactly one user's own enrolled browsers, +//! so OAuth/auth prompts are admissible on it. Shared-conversation encoding +//! deliberately fails closed — the channel has no shared conversations. + +use ironclaw_extension_contracts::external::ExternalConversationRef; +use ironclaw_extension_contracts::preference_target::{ + PreferenceTargetCodec, PreferenceTargetEncodeRequest, +}; +use ironclaw_host_api::turn::ReplyTargetBindingRef; +use ironclaw_web_push::{decode_web_push_target_ref, encode_web_push_target_ref}; + +pub struct WebPushPreferenceTargetCodec; + +impl PreferenceTargetCodec for WebPushPreferenceTargetCodec { + fn conversation_for_target( + &self, + target: &ReplyTargetBindingRef, + ) -> Option { + // Validate the grammar before echoing it as a conversation id. + decode_web_push_target_ref(target.as_str())?; + ExternalConversationRef::new(None, target.as_str(), None, None).ok() + } + + fn is_personal_direct_message(&self, target: &ReplyTargetBindingRef) -> bool { + decode_web_push_target_ref(target.as_str()).is_some() + } + + fn direct_message_actor_for_target(&self, target: &ReplyTargetBindingRef) -> Option { + decode_web_push_target_ref(target.as_str()).map(|(_, user_id)| user_id.to_string()) + } + + fn encode_shared_conversation_target( + &self, + _request: PreferenceTargetEncodeRequest<'_>, + ) -> Option { + // Web push has no shared conversations; fail closed. + None + } + + fn encode_personal_direct_message_target( + &self, + request: PreferenceTargetEncodeRequest<'_>, + external_actor_id: &str, + ) -> Option { + // The conversation id carries the full grammar; re-encode only when + // it decodes and names the proven actor. + let (tenant_id, user_id) = + decode_web_push_target_ref(request.conversation.conversation_id())?; + if user_id.to_string() != external_actor_id { + return None; + } + encode_web_push_target_ref(&tenant_id, &user_id).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ironclaw_host_api::ids::{TenantId, UserId}; + + fn reference() -> ReplyTargetBindingRef { + encode_web_push_target_ref( + &TenantId::new("tenant1").expect("tenant"), + &UserId::new("user1").expect("user"), + ) + .expect("encode") + } + + #[test] + fn web_push_targets_are_personal_direct_messages() { + let codec = WebPushPreferenceTargetCodec; + let reference = reference(); + assert!(codec.is_personal_direct_message(&reference)); + assert_eq!( + codec.direct_message_actor_for_target(&reference).as_deref(), + Some("user1") + ); + let conversation = codec + .conversation_for_target(&reference) + .expect("conversation"); + assert_eq!(conversation.conversation_id(), reference.as_str()); + } + + #[test] + fn foreign_refs_are_refused() { + let codec = WebPushPreferenceTargetCodec; + let foreign = ReplyTargetBindingRef::new("slack/v1/team/chan").expect("ref"); + assert!(!codec.is_personal_direct_message(&foreign)); + assert!(codec.conversation_for_target(&foreign).is_none()); + assert!(codec.direct_message_actor_for_target(&foreign).is_none()); + } +} diff --git a/crates/extensions/packages/web-push/src/targets.rs b/crates/extensions/packages/web-push/src/targets.rs new file mode 100644 index 00000000000..33059f3ca31 --- /dev/null +++ b/crates/extensions/packages/web-push/src/targets.rs @@ -0,0 +1,136 @@ +//! The owner-scoped "Web app" delivery-target catalog provider. +//! +//! One constant target per user — their enrolled browsers. The entry is +//! offered whether or not any browser is currently enrolled: the settings +//! panel needs the row to exist so users can discover and enable the route, +//! and delivery to an empty enrollment set fails closed with a clear reason +//! at send time. + +use async_trait::async_trait; +use ironclaw_outbound::{ + CommunicationModality, DeliveryTargetCapabilities, OutboundDeliveryTargetEntry, + OutboundDeliveryTargetId, OutboundDeliveryTargetOwner, OutboundDeliveryTargetProvider, + OutboundDeliveryTargetScope, OutboundDeliveryTargetSummary, OutboundError, +}; +use ironclaw_web_push::{WEB_PUSH_CHANNEL_NAME, WEB_PUSH_TARGET_ID, encode_web_push_target_ref}; + +/// Stateless: the entry is constant per owner (enrollment counts surface +/// through the web-push status view, not the catalog row). +pub struct WebPushOutboundTargetProvider; + +impl WebPushOutboundTargetProvider { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self + } +} + +fn web_push_entry( + scope: &OutboundDeliveryTargetScope, +) -> Result { + let target_id = OutboundDeliveryTargetId::new(WEB_PUSH_TARGET_ID).map_err(|error| { + tracing::debug!( + target: "ironclaw::web_push", + error = %error, + "web-push target id rejected" + ); + OutboundError::Backend + })?; + let summary = OutboundDeliveryTargetSummary::new( + target_id, + WEB_PUSH_CHANNEL_NAME, + "Web app", + Some("Browser push notifications to your enrolled devices".to_string()), + ) + .map_err(|error| { + tracing::debug!( + target: "ironclaw::web_push", + error = %error, + "web-push target summary rejected" + ); + OutboundError::Backend + })?; + let destination = + encode_web_push_target_ref(&scope.tenant_id, &scope.user_id).map_err(|error| { + tracing::debug!( + target: "ironclaw::web_push", + error = %error, + "web-push destination encoding failed" + ); + OutboundError::Backend + })?; + Ok(OutboundDeliveryTargetEntry { + summary, + capabilities: DeliveryTargetCapabilities { + final_replies: true, + progress: false, + gate_prompts: true, + auth_prompts: true, + modalities: vec![CommunicationModality::Text], + }, + destination, + owner: OutboundDeliveryTargetOwner::for_scope(scope), + }) +} + +#[async_trait] +impl OutboundDeliveryTargetProvider for WebPushOutboundTargetProvider { + async fn list_outbound_delivery_targets( + &self, + scope: &OutboundDeliveryTargetScope, + ) -> Result, OutboundError> { + Ok(vec![web_push_entry(scope)?]) + } +} + +/// Registry key composition registers the provider under. +pub const WEB_PUSH_TARGET_PROVIDER_KEY: &str = "web-push"; + +#[cfg(test)] +mod tests { + use super::*; + use ironclaw_host_api::ids::{TenantId, UserId}; + use ironclaw_web_push::decode_web_push_target_ref; + + fn scope() -> OutboundDeliveryTargetScope { + OutboundDeliveryTargetScope::new( + TenantId::new("tenant1").expect("tenant"), + UserId::new("user1").expect("user"), + ) + } + + #[tokio::test] + async fn the_entry_is_owner_scoped_and_decodes_back_to_the_owner() { + let provider = WebPushOutboundTargetProvider::new(); + let entries = provider + .list_outbound_delivery_targets(&scope()) + .await + .expect("list"); + assert_eq!(entries.len(), 1); + let entry = &entries[0]; + assert_eq!(entry.summary.target_id.as_str(), WEB_PUSH_TARGET_ID); + assert_eq!(entry.summary.channel.as_str(), WEB_PUSH_CHANNEL_NAME); + assert!(entry.capabilities.final_replies); + assert!(entry.capabilities.gate_prompts); + assert!(entry.capabilities.auth_prompts); + assert!(!entry.capabilities.progress); + let (tenant, user) = + decode_web_push_target_ref(entry.destination.as_str()).expect("decodes"); + assert_eq!(tenant.to_string(), "tenant1"); + assert_eq!(user.to_string(), "user1"); + assert!(entry.owner.matches_scope(&scope())); + } + + #[tokio::test] + async fn resolve_by_id_uses_the_default_final_replies_gate() { + let provider = WebPushOutboundTargetProvider::new(); + let resolved = provider + .resolve_outbound_delivery_target( + &scope(), + &OutboundDeliveryTargetId::new(WEB_PUSH_TARGET_ID).expect("id"), + ) + .await + .expect("resolve"); + assert!(resolved.is_some(), "final_replies=true keeps it resolvable"); + } +} diff --git a/crates/extensions/packages/web-push/tests/manifest_lockstep.rs b/crates/extensions/packages/web-push/tests/manifest_lockstep.rs new file mode 100644 index 00000000000..33d8e5ee99e --- /dev/null +++ b/crates/extensions/packages/web-push/tests/manifest_lockstep.rs @@ -0,0 +1,78 @@ +//! Manifest shape pins: the egress declarations are the deployment's push +//! host allowlist (composition reads them back for enrollment validation), +//! so their structure — https-only hosts, the VAPID credential handle, the +//! `vapid_authorization` injection — must not drift. + +const MANIFEST: &str = ironclaw_web_push_extension::MANIFEST; + +#[test] +fn every_egress_entry_is_a_vapid_injected_https_push_host() { + let parsed: toml::Value = toml::from_str(MANIFEST).expect("manifest parses as TOML"); + let egress = parsed + .get("channel") + .and_then(|channel| channel.get("egress")) + .and_then(toml::Value::as_array) + .expect("manifest declares [[channel.egress]]"); + assert!( + !egress.is_empty(), + "an empty egress set would leave enrollment with no admissible push service" + ); + + for entry in egress { + let host = entry + .get("host") + .and_then(toml::Value::as_str) + .expect("every egress entry declares a host"); + assert!(!host.trim().is_empty() && !host.contains('*')); + assert_eq!( + entry.get("scheme").and_then(toml::Value::as_str), + Some("https"), + "push services are https-only" + ); + assert_eq!( + entry.get("credential_handle").and_then(toml::Value::as_str), + Some(ironclaw_web_push::WEB_PUSH_VAPID_CREDENTIAL_HANDLE), + "every egress entry injects the VAPID credential" + ); + let injection = entry + .get("injection") + .and_then(|injection| injection.get("type")) + .and_then(toml::Value::as_str); + assert_eq!( + injection, + Some("vapid_authorization"), + "every egress entry uses the vapid_authorization injection" + ); + let prefixes = entry + .get("path_prefixes") + .and_then(toml::Value::as_array) + .expect("endpoint paths are opaque tokens, so the constraint is a prefix"); + assert_eq!( + prefixes + .iter() + .map(|prefix| prefix.as_str().unwrap_or_default()) + .collect::>(), + vec!["/"], + "the whole-host prefix is the declared constraint" + ); + } +} + +#[test] +fn manifest_identity_matches_the_grammar_constants() { + let parsed: toml::Value = toml::from_str(MANIFEST).expect("manifest parses as TOML"); + assert_eq!( + parsed.get("id").and_then(toml::Value::as_str), + Some(ironclaw_web_push::WEB_PUSH_EXTENSION_ID) + ); + let channel = parsed.get("channel").expect("channel surface declared"); + assert_eq!( + channel.get("inbound").and_then(toml::Value::as_bool), + Some(false), + "web push is outbound-only" + ); + assert_eq!( + channel.get("outbound").and_then(toml::Value::as_bool), + Some(true) + ); +} diff --git a/crates/kernel/ironclaw_host_runtime/Cargo.toml b/crates/kernel/ironclaw_host_runtime/Cargo.toml index 35a5a0692a4..d8754a7c192 100644 --- a/crates/kernel/ironclaw_host_runtime/Cargo.toml +++ b/crates/kernel/ironclaw_host_runtime/Cargo.toml @@ -12,6 +12,10 @@ test-support = [] [dependencies] async-trait = "0.1" +# ES256 signing for the RFC 8292 `vapid_authorization` egress credential +# injection (Web Push). Already in the lockfile via jsonwebtoken's +# aws_lc_rs backend; no new lockfile entry. +aws-lc-rs = "1" base64 = "0.23.0" chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono-tz = "0.10" diff --git a/crates/kernel/ironclaw_host_runtime/src/egress/credential.rs b/crates/kernel/ironclaw_host_runtime/src/egress/credential.rs index 3678f988310..c278335ca38 100644 --- a/crates/kernel/ironclaw_host_runtime/src/egress/credential.rs +++ b/crates/kernel/ironclaw_host_runtime/src/egress/credential.rs @@ -528,6 +528,24 @@ fn apply_credential_injection( reason: "credential injection body did not re-serialize".to_string(), })?; } + RuntimeCredentialTarget::VapidAuthorization => { + let now_unix_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .map_err(|_| RuntimeHttpEgressError::Credential { + reason: "system clock is before the Unix epoch".to_string(), + })?; + let url = parsed_request_url(&request.url, parsed_url)?; + let header_value = + super::vapid::vapid_authorization_header(value, url, now_unix_seconds).map_err( + |error| RuntimeHttpEgressError::Credential { + reason: error.sanitized_reason().to_string(), + }, + )?; + request + .headers + .push(("authorization".to_string(), header_value)); + } } Ok(()) } diff --git a/crates/kernel/ironclaw_host_runtime/src/egress/mod.rs b/crates/kernel/ironclaw_host_runtime/src/egress/mod.rs index 7359ed32971..09c1ce01f95 100644 --- a/crates/kernel/ironclaw_host_runtime/src/egress/mod.rs +++ b/crates/kernel/ironclaw_host_runtime/src/egress/mod.rs @@ -2,6 +2,7 @@ mod credential; mod host_port; mod pipeline; mod sanitize; +mod vapid; use async_trait::async_trait; use ironclaw_host_api::{ diff --git a/crates/kernel/ironclaw_host_runtime/src/egress/vapid.rs b/crates/kernel/ironclaw_host_runtime/src/egress/vapid.rs new file mode 100644 index 00000000000..66063447b25 --- /dev/null +++ b/crates/kernel/ironclaw_host_runtime/src/egress/vapid.rs @@ -0,0 +1,194 @@ +//! RFC 8292 `Authorization: vapid` computation for the +//! `RuntimeCredentialTarget::VapidAuthorization` injection kind (Web Push). +//! +//! Runs at the same credential-injection chokepoint as header/query/path +//! injection: the resolved secret material is a serialized +//! [`VapidCredentialMaterialV1`], and the signed token's audience is derived +//! from the request URL actually being sent, so a token can never authorize +//! a different push service than the one this request targets. Adapters +//! never see key bytes — they only name the credential handle. + +use aws_lc_rs::rand as aws_rand; +use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ironclaw_host_api::http::VapidCredentialMaterialV1; + +/// RFC 8292 caps token lifetime at 24 hours; half that keeps clock-skew +/// headroom while staying reusable across a retry burst. +pub(super) const VAPID_TOKEN_TTL_SECONDS: u64 = 12 * 60 * 60; + +/// Sanitized failure reasons — never carry material or the request URL. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VapidHeaderError { + MaterialInvalid, + AudienceInvalid, + SigningFailed, +} + +impl VapidHeaderError { + pub(super) fn sanitized_reason(self) -> &'static str { + match self { + Self::MaterialInvalid => "VAPID credential material is invalid", + Self::AudienceInvalid => "VAPID audience could not be derived from the request", + Self::SigningFailed => "VAPID token signing failed", + } + } +} + +/// Compute the `Authorization: vapid t=, k=` header value for a +/// request to `request_url`, signed with the deployment's stored material. +pub(super) fn vapid_authorization_header( + material_json: &str, + request_url: &url::Url, + now_unix_seconds: u64, +) -> Result { + let material: VapidCredentialMaterialV1 = + serde_json::from_str(material_json).map_err(|_| VapidHeaderError::MaterialInvalid)?; + + // Push service audiences are https origins; the outer injection gate has + // already required TLS (or literal loopback, which never hosts a push + // service), so anything non-https fails closed here too. + if request_url.scheme() != "https" { + return Err(VapidHeaderError::AudienceInvalid); + } + let host = request_url + .host_str() + .ok_or(VapidHeaderError::AudienceInvalid)?; + let audience = match request_url.port() { + Some(port) => format!("https://{host}:{port}"), + None => format!("https://{host}"), + }; + + // Sanity-check the public half so a corrupted blob fails before any + // request carries a token the push service cannot attribute. + let public_key = URL_SAFE_NO_PAD + .decode(&material.public_key_b64url) + .map_err(|_| VapidHeaderError::MaterialInvalid)?; + if public_key.len() != 65 || public_key[0] != 0x04 { + return Err(VapidHeaderError::MaterialInvalid); + } + + let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"ES256"}"#); + let claims = serde_json::json!({ + "aud": audience, + "exp": now_unix_seconds + VAPID_TOKEN_TTL_SECONDS, + "sub": material.subject, + }); + let payload = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&claims).map_err(|_| VapidHeaderError::SigningFailed)?); + let signing_input = format!("{header}.{payload}"); + + let private_key_der = URL_SAFE_NO_PAD + .decode(&material.es256_private_key_pkcs8_b64url) + .map_err(|_| VapidHeaderError::MaterialInvalid)?; + let key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &private_key_der) + .map_err(|_| VapidHeaderError::MaterialInvalid)?; + let rng = aws_rand::SystemRandom::new(); + let signature = key_pair + .sign(&rng, signing_input.as_bytes()) + .map_err(|_| VapidHeaderError::SigningFailed)?; + + Ok(format!( + "vapid t={signing_input}.{}, k={}", + URL_SAFE_NO_PAD.encode(signature.as_ref()), + material.public_key_b64url + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED, KeyPair as _, UnparsedPublicKey}; + + fn generated_material(subject: &str) -> (String, Vec) { + let rng = aws_rand::SystemRandom::new(); + let document = + EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).expect("generate"); + let key_pair = + EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, document.as_ref()) + .expect("parse"); + let public_key = key_pair.public_key().as_ref().to_vec(); + let material = VapidCredentialMaterialV1 { + es256_private_key_pkcs8_b64url: URL_SAFE_NO_PAD.encode(document.as_ref()), + public_key_b64url: URL_SAFE_NO_PAD.encode(&public_key), + subject: subject.to_string(), + }; + ( + serde_json::to_string(&material).expect("material serializes"), + public_key, + ) + } + + #[test] + fn header_carries_a_verifiable_jwt_scoped_to_the_push_origin() { + let (material_json, public_key) = generated_material("mailto:ops@example.com"); + let request_url = + url::Url::parse("https://fcm.googleapis.com/fcm/send/token123").expect("url"); + let now = 1_754_000_000u64; + + let header = vapid_authorization_header(&material_json, &request_url, now).expect("header"); + let token_section = header + .strip_prefix("vapid t=") + .expect("vapid scheme prefix"); + let (jwt, key_section) = token_section.split_once(", k=").expect("k parameter"); + assert_eq!(key_section, URL_SAFE_NO_PAD.encode(&public_key)); + + let mut segments = jwt.split('.'); + let header_b64 = segments.next().expect("jwt header"); + let payload_b64 = segments.next().expect("jwt payload"); + let signature_b64 = segments.next().expect("jwt signature"); + assert!(segments.next().is_none(), "compact JWS has three segments"); + + let decoded_header: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(header_b64).expect("header decodes")) + .expect("header json"); + assert_eq!(decoded_header["alg"], "ES256"); + + let claims: serde_json::Value = serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(payload_b64) + .expect("payload decodes"), + ) + .expect("claims json"); + assert_eq!(claims["aud"], "https://fcm.googleapis.com"); + assert_eq!(claims["sub"], "mailto:ops@example.com"); + assert_eq!(claims["exp"], now + VAPID_TOKEN_TTL_SECONDS); + + let verifier = UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, &public_key); + let signing_input = format!("{header_b64}.{payload_b64}"); + verifier + .verify( + signing_input.as_bytes(), + &URL_SAFE_NO_PAD + .decode(signature_b64) + .expect("signature decodes"), + ) + .expect("ES256 signature verifies against the advertised public key"); + } + + #[test] + fn malformed_material_and_non_https_audiences_fail_closed() { + let (material_json, _) = generated_material("mailto:ops@example.com"); + let https_url = url::Url::parse("https://fcm.googleapis.com/send/x").expect("url"); + assert_eq!( + vapid_authorization_header("not json", &https_url, 0), + Err(VapidHeaderError::MaterialInvalid) + ); + let truncated = { + let mut material: VapidCredentialMaterialV1 = + serde_json::from_str(&material_json).expect("parse"); + material.public_key_b64url = URL_SAFE_NO_PAD.encode([0u8; 10]); + serde_json::to_string(&material).expect("serialize") + }; + assert_eq!( + vapid_authorization_header(&truncated, &https_url, 0), + Err(VapidHeaderError::MaterialInvalid) + ); + let http_url = url::Url::parse("http://127.0.0.1/send/x").expect("url"); + assert_eq!( + vapid_authorization_header(&material_json, &http_url, 0), + Err(VapidHeaderError::AudienceInvalid) + ); + } +} diff --git a/crates/kernel/ironclaw_host_runtime/src/first_party_tools/schemas.rs b/crates/kernel/ironclaw_host_runtime/src/first_party_tools/schemas.rs index 343307b63ae..eefac0e830a 100644 --- a/crates/kernel/ironclaw_host_runtime/src/first_party_tools/schemas.rs +++ b/crates/kernel/ironclaw_host_runtime/src/first_party_tools/schemas.rs @@ -907,7 +907,7 @@ pub(crate) fn resolve_builtin_input_schema_ref(reference: &str) -> Option }, "prompt": { "type": "string", - "description": "Prompt submitted when the trigger fires, written for a future run with no memory of this conversation. Write the whole task, including any delivery the user wants: name the destination in an explicit step by pinned target id from builtin__outbound_delivery_targets_list, picked while the user is present (for example \"then deliver the summary with builtin__outbound_deliver to chat:team-dm\" — never a description a fire would have to look up). A bare \"send me\" means the surface the user is asking from — never ask which channel: from a channel conversation, default to the channel this conversation is on and pin its target id; from the web app with no external destination named there is no delivery step and no web-app target: the fire's final reply IS the delivery (the run thread records it), so end the prompt with the reply itself and write no delivery step. When the user names an external destination (\"send me this in my messaging app\"), that IS a delivery step even from the web app: pin its target id and write the builtin__outbound_deliver step — reaching the user or anyone else on an external surface always goes through builtin__outbound_deliver, never through integration messaging tools, which act as the user. Each fire's final reply is recorded in the routine's own run thread automatically, so a fire that makes no delivery call delivers nothing externally. Do not describe creating, scheduling, or configuring the trigger. Runtime validation caps UTF-8 content at 32768 bytes." + "description": "Prompt submitted when the trigger fires, written for a future run with no memory of this conversation. Write the whole task, including any delivery the user wants: name the destination in an explicit step by pinned target id from builtin__outbound_delivery_targets_list, picked while the user is present (for example \"then deliver the summary with builtin__outbound_deliver to chat:team-dm\" — never a description a fire would have to look up). A bare \"send me\" means the surface the user is asking from — never ask which channel: from a channel conversation, default to the channel this conversation is on and pin its target id; from the web app with no external destination named there is no delivery step: the fire's final reply IS the delivery (the run thread records it), so end the prompt with the reply itself and write no delivery step; only an explicit ask to be notified in the browser makes the catalog's browser-push target a destination to pin. When the user names an external destination (\"send me this in my messaging app\"), that IS a delivery step even from the web app: pin its target id and write the builtin__outbound_deliver step — reaching the user or anyone else on an external surface always goes through builtin__outbound_deliver, never through integration messaging tools, which act as the user. Each fire's final reply is recorded in the routine's own run thread automatically, so a fire that makes no delivery call delivers nothing externally. Do not describe creating, scheduling, or configuring the trigger. Runtime validation caps UTF-8 content at 32768 bytes." }, "schedule": { "description": "When and how often the trigger fires. This value is the schedule object itself. For recurring triggers use {\"kind\":\"cron\",\"expression\":\"0 14 * * 2\",\"timezone\":\"America/Los_Angeles\"}. For one-time triggers use {\"kind\":\"once\",\"at\":\"2026-06-23T14:00:00\",\"timezone\":\"America/Los_Angeles\"}. Do not pass {\"operation\":\"parse\",\"data\":...}.", diff --git a/crates/kernel/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs b/crates/kernel/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs index 87581a9e8fc..7aca1b9ba46 100644 --- a/crates/kernel/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs +++ b/crates/kernel/ironclaw_host_runtime/src/first_party_tools/trigger_management.rs @@ -40,7 +40,7 @@ pub const TRIGGER_REMOVE_CAPABILITY_ID: &str = "builtin.trigger_remove"; pub const TRIGGER_PAUSE_CAPABILITY_ID: &str = "builtin.trigger_pause"; pub const TRIGGER_RESUME_CAPABILITY_ID: &str = "builtin.trigger_resume"; -const TRIGGER_CREATE_DESCRIPTION: &str = "Create a scheduled routine. The prompt is the full task each fire performs, written for a future run with no memory of this conversation. Where results go: a bare \"send me\" or \"notify me\" means the surface the user is asking from — never ask which channel. From a channel conversation, default to the channel this conversation is on: pick its target id from builtin__outbound_delivery_targets_list while the user is present and write that as an explicit step in the prompt naming the destination by pinned id (e.g. \"then deliver the summary with builtin__outbound_deliver to chat:team-dm\" — never a description like \"my DM\" that a fire would have to look up). From the web app with no external destination named, there is no delivery step to write and no web-app target exists: the fire's final reply IS the delivery — it lands in the routine's own run thread automatically — so end the prompt with the reply itself and write no delivery step. When the user names an external destination (\"send me this in my messaging app\", \"post it to the team channel\"), that IS a delivery step even from the web app: pin its target id the same way — reaching the user or anyone else on an external surface always goes through builtin__outbound_deliver with a pinned target id, never through integration messaging tools, which act as the user toward other people. Several destinations mean one delivery step each; a fire that makes no delivery call delivers nothing externally."; +const TRIGGER_CREATE_DESCRIPTION: &str = "Create a scheduled routine. The prompt is the full task each fire performs, written for a future run with no memory of this conversation. Where results go: a bare \"send me\" or \"notify me\" means the surface the user is asking from — never ask which channel. From a channel conversation, default to the channel this conversation is on: pick its target id from builtin__outbound_delivery_targets_list while the user is present and write that as an explicit step in the prompt naming the destination by pinned id (e.g. \"then deliver the summary with builtin__outbound_deliver to chat:team-dm\" — never a description like \"my DM\" that a fire would have to look up). From the web app with no external destination named, there is no delivery step to write: the fire's final reply IS the delivery — it lands in the routine's own run thread automatically — so end the prompt with the reply itself and write no delivery step. Only when the user explicitly asks to be notified in the browser or on their devices does the catalog's browser-push target apply: pin its target id like any other destination. When the user names an external destination (\"send me this in my messaging app\", \"post it to the team channel\"), that IS a delivery step even from the web app: pin its target id the same way — reaching the user or anyone else on an external surface always goes through builtin__outbound_deliver with a pinned target id, never through integration messaging tools, which act as the user toward other people. Several destinations mean one delivery step each; a fire that makes no delivery call delivers nothing externally."; pub(super) fn manifests() -> Result, ExtensionError> { Ok(vec![ diff --git a/crates/lanes/ironclaw_sandbox/src/plan.rs b/crates/lanes/ironclaw_sandbox/src/plan.rs index a5aa312baf3..165b8a52ad1 100644 --- a/crates/lanes/ironclaw_sandbox/src/plan.rs +++ b/crates/lanes/ironclaw_sandbox/src/plan.rs @@ -323,7 +323,8 @@ impl SandboxCredentialBinding { } RuntimeCredentialTarget::QueryParam { .. } | RuntimeCredentialTarget::PathPlaceholder { .. } - | RuntimeCredentialTarget::BodyJsonPointer { .. } => { + | RuntimeCredentialTarget::BodyJsonPointer { .. } + | RuntimeCredentialTarget::VapidAuthorization => { return Err(ProcessSandboxPlanError::UnsupportedCredentialTarget); } } @@ -335,7 +336,8 @@ impl SandboxCredentialBinding { RuntimeCredentialTarget::Header { name, .. } => name.to_ascii_lowercase(), RuntimeCredentialTarget::QueryParam { name } => name.to_ascii_lowercase(), RuntimeCredentialTarget::PathPlaceholder { .. } - | RuntimeCredentialTarget::BodyJsonPointer { .. } => { + | RuntimeCredentialTarget::BodyJsonPointer { .. } + | RuntimeCredentialTarget::VapidAuthorization => { unreachable!("non-header targets are rejected during sandbox credential validation") } } diff --git a/crates/product/ironclaw_assistant/AGENTS.md b/crates/product/ironclaw_assistant/AGENTS.md index 55abd32f539..3844df4c60a 100644 --- a/crates/product/ironclaw_assistant/AGENTS.md +++ b/crates/product/ironclaw_assistant/AGENTS.md @@ -323,6 +323,7 @@ by file. | `inspector` | Operator-only reads of bounded, process-local run diagnostics and their resumable update batches | Normal product projections, durable events, or diagnostic capture policy — this owner only authorizes and reads the shared store | `mod inspector`, `reborn_services/inspector.rs::diagnostic_scope`, `reborn_services/inspector.rs::store_error`, `reborn_services/inspector.rs::snapshot`, `reborn_services/inspector.rs::prompt`, `reborn_services/inspector.rs::tool`, `reborn_services/inspector.rs::updates` | | `automations` | Automation listing and lifecycle (pause/resume/rename/delete), automation name validation, trigger-thread recognition, and the notification-approval candidate scan | Trigger evaluation or scheduling — that is `ironclaw_triggers` | `AUTOMATION_PAUSE_CAPABILITY_ID`, `AUTOMATION_PAUSE_CAPABILITY`, `AUTOMATION_RESUME_CAPABILITY_ID`, `AUTOMATION_RESUME_CAPABILITY`, `AUTOMATION_RENAME_CAPABILITY_ID`, `AUTOMATION_RENAME_CAPABILITY`, `AUTOMATION_DELETE_CAPABILITY_ID`, `AUTOMATION_DELETE_CAPABILITY`, `AUTOMATION_PAUSE_COMMAND_ID`, `AUTOMATION_PAUSE_COMMAND`, `AUTOMATION_RESUME_COMMAND_ID`, `AUTOMATION_RESUME_COMMAND`, `AUTOMATION_RENAME_COMMAND_ID`, `AUTOMATION_RENAME_COMMAND`, `AUTOMATION_DELETE_COMMAND_ID`, `AUTOMATION_DELETE_COMMAND`, `AUTOMATIONS_VIEW`, `AutomationListRequest`, `TriggerRunThreadScope`, `AutomationNotificationTitle`, `AutomationApprovalThreadCandidate`, `AutomationProductService`, `UnsupportedAutomationProductService`, `automation_unavailable`, `is_automation_trigger_thread`, `AUTOMATION_LIST_DEFAULT_PAGE_SIZE`, `AUTOMATION_LIST_MAX_PAGE_SIZE`, `AUTOMATION_RUN_HISTORY_DEFAULT_PAGE_SIZE`, `AUTOMATION_RUN_HISTORY_MAX_PAGE_SIZE`, `NOTIFICATION_APPROVAL_AUTOMATION_LIMIT`, `NOTIFICATION_APPROVAL_RUN_LIMIT`, `NOTIFICATION_APPROVAL_CANDIDATE_LIMIT`, `NOTIFICATION_APPROVAL_QUERY_TIMEOUT`, `clamp_automation_list_limit`, `clamp_automation_run_limit`, `parse_automation_name`, `automation_name_validation_code`, `automation_name_validation_error`, `notification_approval_timeout_error` | | `outbound` | Outbound delivery targets, notification preferences, and the outbound delivery capability surface the model calls | Delivery itself — the host-owned `DeliveryCoordinator` owns that | `mod outbound_delivery_capability_surface`, `mod outbound_preferences`, `mod outbound_views`, `NOTIFICATION_CHANNELS_SET_COMMAND`, `NOTIFICATION_CHANNELS_SET_COMMAND_ID`, `OutboundPreferencesProductService`, `UnsupportedOutboundPreferencesProductService`, `outbound_preferences_unavailable`, `reborn_services/outbound_delivery_capability_surface.rs::OUTBOUND_DELIVERY_SYNTHETIC_PROVIDER_ID`, `reborn_services/outbound_delivery_capability_surface.rs::OUTBOUND_DELIVERY_TARGETS_LIST_CAPABILITY_ID`, `reborn_services/outbound_delivery_capability_surface.rs::OUTBOUND_DELIVERY_TARGETS_LIST_PROVIDER_TOOL_NAME`, `reborn_services/outbound_delivery_capability_surface.rs::OUTBOUND_DELIVERY_TARGETS_LIST_DESCRIPTION`, `reborn_services/outbound_delivery_capability_surface.rs::OUTBOUND_NOTIFICATION_CHANNELS_SET_CAPABILITY_ID`, `reborn_services/outbound_delivery_capability_surface.rs::NOTIFICATION_CHANNELS_SET_MAX_ITEMS`, `reborn_services/outbound_delivery_capability_surface.rs::OUTBOUND_NOTIFICATION_CHANNELS_SET_PROVIDER_TOOL_NAME`, `reborn_services/outbound_delivery_capability_surface.rs::OUTBOUND_NOTIFICATION_CHANNELS_SET_DESCRIPTION`, `reborn_services/outbound_delivery_capability_surface.rs::outbound_delivery_synthetic_provider`, `reborn_services/outbound_delivery_capability_surface.rs::notification_channels_set_operator_tool_info`, `reborn_services/outbound_delivery_capability_surface.rs::OutboundDeliveryTargetsListInput`, `reborn_services/outbound_delivery_capability_surface.rs::NotificationChannelsSetInput`, `reborn_services/outbound_delivery_capability_surface.rs::OutboundDeliveryCapabilityInputError`, `reborn_services/outbound_delivery_capability_surface.rs::list_outbound_delivery_targets_for_model`, `reborn_services/outbound_delivery_capability_surface.rs::set_notification_channels_for_model`, `reborn_services/outbound_delivery_capability_surface.rs::outbound_delivery_targets_list_input_schema`, `reborn_services/outbound_delivery_capability_surface.rs::notification_channels_set_input_schema`, `reborn_services/outbound_delivery_capability_surface.rs::parse_outbound_delivery_targets_list_input`, `reborn_services/outbound_delivery_capability_surface.rs::parse_notification_channels_set_input`, `reborn_services/outbound_delivery_capability_surface.rs::input_object`, `reborn_services/outbound_preferences.rs::RebornOutboundPreferencesService`, `reborn_services/outbound_preferences.rs::target_scope`, `reborn_services/outbound_preferences.rs::reborn_target_id_from_outbound`, `reborn_services/outbound_preferences.rs::mod notification_channels`, `reborn_services/outbound_preferences.rs::reborn_summary_from_outbound`, `reborn_services/outbound_preferences.rs::reborn_capabilities_from_outbound`, `reborn_services/outbound_preferences.rs::outbound_target_projection_error`, `reborn_services/outbound_preferences.rs::map_outbound_repository_error`, `reborn_services/outbound_views.rs::NOTIFICATION_CHANNELS_VIEW`, `reborn_services/outbound_views.rs::OUTBOUND_DELIVERY_TARGETS_VIEW` | +| `web-push` | Browser push enrollment: the service behind the contracts-declared status view and subscribe/unsubscribe commands (`ironclaw_product_contracts::web_push` owns the descriptors) | Push *delivery* — the web-push channel adapter behind the delivery coordinator owns that | `mod web_push`, `reborn_services/web_push.rs::WebPushProductService`, `reborn_services/web_push.rs::UnsupportedWebPushProductService`, `reborn_services/web_push.rs::RebornWebPushProductService`, `reborn_services/web_push.rs::web_push_unavailable`, `reborn_services/web_push.rs::map_web_push_error` | | `threads` | Thread lifecycle and access resolution, the thread-list and timeline reads, their page-size clamps and cursor codec, and per-thread operation locking | Turn submission or run control (that is `runs`), and artifact export (that is `run-artifact`) | `THREAD_DELETE_CAPABILITY_ID`, `THREAD_DELETE_CAPABILITY`, `CREATE_THREAD_COMMAND_ID`, `CREATE_THREAD_COMMAND`, `THREADS_VIEW`, `TIMELINE_VIEW`, `ThreadOperationLocks`, `ResolvedThreadAccess`, `map_ownership_probe_error`, `thread_scope_from_turn_scope`, `parse_thread_id_field`, `thread_operation_key`, `TIMELINE_DEFAULT_PAGE_SIZE`, `TIMELINE_MAX_PAGE_SIZE`, `TIMELINE_MAX_SUMMARY_ARTIFACTS`, `THREAD_LIST_DEFAULT_PAGE_SIZE`, `THREAD_LIST_MAX_PAGE_SIZE`, `THREAD_LIST_FILTER_MIN_FETCH_SIZE`, `THREAD_LIST_FILTER_MAX_PAGES`, `clamp_timeline_limit`, `clamp_thread_list_limit`, `TimelineCursor`, `parse_timeline_cursor`, `serialize_timeline_cursor`, `paginate_timeline_messages`, `cap_summary_artifacts`, `map_timeline_probe_error`, `map_thread_error`, `delete_thread_busy`, `create_thread_metadata_json`, `generated_thread_id`, `reborn_services/types.rs::RebornCreateThreadResponse`, `reborn_services/types.rs::RebornTimelineResponse`, `reborn_services/types.rs::RebornListThreadsResponse` | | `runs` | Turn submission, cancel, retry, run state, steering admission, and the WebUI accepted-message/replay idempotency plumbing that feeds them | Gate resolution (that is `gates`) and stream transport (that is `projections`) | `SUBMIT_TURN_COMMAND_ID`, `SUBMIT_TURN_COMMAND`, `CANCEL_RUN_COMMAND_ID`, `CANCEL_RUN_COMMAND`, `RETRY_RUN_COMMAND_ID`, `RETRY_RUN_COMMAND`, `NOTICE_BUSY_GENERIC`, `describe_turn_status`, `rejected_busy_notice`, `AcceptedWebUiMessage`, `mark_message_submitted_or_replay`, `reconcile_terminal_duplicate`, `replay_webui_send_message`, `replay_accepted_message`, `parse_run_id_field`, `parse_persisted_turn_run_id`, `accepted_message_ref`, `steering_admission_error`, `parse_replay_run_id`, `webui_source_binding_ref_from_raw`, `webui_reply_target_binding_ref_from_raw`, `webui_source_binding_id`, `legacy_webui_source_binding_id`, `map_turn_error`, `reborn_services/types.rs::reborn_cancel_run_response`, `reborn_services/types.rs::reborn_retry_run_response`, `reborn_services/types.rs::RebornGetRunStateResponse` | | `projects` | Project CRUD, project membership, and project-scoped filesystem reads | Workspace-wide mount browsing — that is `workspace-fs` | `mod project_fs`, `mod projects`, `PROJECT_UPDATE_CAPABILITY_ID`, `PROJECT_UPDATE_CAPABILITY`, `PROJECT_DELETE_CAPABILITY_ID`, `PROJECT_DELETE_CAPABILITY`, `PROJECT_MEMBER_ADD_CAPABILITY_ID`, `PROJECT_MEMBER_ADD_CAPABILITY`, `PROJECT_MEMBER_UPDATE_CAPABILITY_ID`, `PROJECT_MEMBER_UPDATE_CAPABILITY`, `PROJECT_MEMBER_REMOVE_CAPABILITY_ID`, `PROJECT_MEMBER_REMOVE_CAPABILITY`, `PROJECT_CREATE_COMMAND_ID`, `PROJECT_CREATE_COMMAND`, `PROJECT_FS_READ_COMMAND_ID`, `PROJECT_FS_READ_COMMAND`, `PROJECT_FS_LIST_VIEW`, `PROJECT_FS_STAT_VIEW`, `PROJECTS_VIEW`, `PROJECT_VIEW`, `PROJECT_MEMBERS_VIEW`, `map_project_fs_error`, `project_caller`, `map_project_service_error`, `reborn_services/project_fs.rs::ProjectFilesystemReader`, | diff --git a/crates/product/ironclaw_assistant/Cargo.toml b/crates/product/ironclaw_assistant/Cargo.toml index 248e0e883a2..4f901086b26 100644 --- a/crates/product/ironclaw_assistant/Cargo.toml +++ b/crates/product/ironclaw_assistant/Cargo.toml @@ -44,6 +44,7 @@ ironclaw_extension_contracts = { path = "../../contracts/ironclaw_extension_cont ironclaw_loop_host = { path = "../../loop/ironclaw_loop_host", version = "0.1.0" } ironclaw_conversations = { path = "../../domains/ironclaw_conversations", version = "0.1.0" } ironclaw_outbound = { path = "../../domains/ironclaw_outbound", version = "0.1.0" } +ironclaw_web_push = { path = "../../domains/ironclaw_web_push", version = "0.1.0" } # The blocked-auth resume fan-out (WS6 eviction from the composition root) # reads durable process-gate records to find the caller's other parked runs. ironclaw_processes = { path = "../../kernel/ironclaw_processes", version = "0.1.0" } diff --git a/crates/product/ironclaw_assistant/src/lib.rs b/crates/product/ironclaw_assistant/src/lib.rs index 4b5507d2ad2..ad7ffb29ddb 100644 --- a/crates/product/ironclaw_assistant/src/lib.rs +++ b/crates/product/ironclaw_assistant/src/lib.rs @@ -357,8 +357,8 @@ pub use reborn_services::{ RebornThreadArtifact, RebornThreadArtifactRequest, RebornTimelineRequest, RebornTimelineResponse, RebornTraceCreditsResponse, RebornTraceHoldAuthorizeProductRequest, RebornTraceHoldAuthorizeResponse, RebornUpdateMemberRoleRequest, RebornUpdateProjectRequest, - RebornVendorAuthAccounts, RunArtifactLogs, RunArtifactMessage, RunArtifactRedaction, - RunArtifactToolCall, SKILL_AUTO_ACTIVATE_LEARNED_SET_CAPABILITY, + RebornVendorAuthAccounts, RebornWebPushProductService, RunArtifactLogs, RunArtifactMessage, + RunArtifactRedaction, RunArtifactToolCall, SKILL_AUTO_ACTIVATE_LEARNED_SET_CAPABILITY, SKILL_AUTO_ACTIVATE_LEARNED_SET_CAPABILITY_ID, SKILL_AUTO_ACTIVATE_SET_CAPABILITY, SKILL_AUTO_ACTIVATE_SET_CAPABILITY_ID, SKILL_CONTENT_VIEW, SKILL_INSTALL_CAPABILITY, SKILL_INSTALL_CAPABILITY_ID, SKILL_REMOVE_CAPABILITY, SKILL_REMOVE_CAPABILITY_ID, @@ -370,11 +370,12 @@ pub use reborn_services::{ TRACE_HOLD_AUTHORIZE_COMMAND, TriggerRunThreadScope, UnavailableRebornViewProvider, UnsupportedAutomationProductService, UnsupportedOperatorLogsService, UnsupportedOperatorServiceLifecycleService, UnsupportedOperatorStatusService, - UnsupportedOutboundPreferencesProductService, list_outbound_delivery_targets_for_model, - notification_channels_set_input_schema, notification_channels_set_operator_tool_info, - outbound_delivery_synthetic_provider, outbound_delivery_targets_list_input_schema, - parse_notification_channels_set_input, parse_outbound_delivery_targets_list_input, - set_notification_channels_for_model, + UnsupportedOutboundPreferencesProductService, UnsupportedWebPushProductService, + WebPushProductService, + list_outbound_delivery_targets_for_model, notification_channels_set_input_schema, + notification_channels_set_operator_tool_info, outbound_delivery_synthetic_provider, + outbound_delivery_targets_list_input_schema, parse_notification_channels_set_input, + parse_outbound_delivery_targets_list_input, set_notification_channels_for_model, }; pub use product_surface_inbound::{ diff --git a/crates/product/ironclaw_assistant/src/reborn_services.rs b/crates/product/ironclaw_assistant/src/reborn_services.rs index 9f5bba17544..401a67f0423 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services.rs @@ -148,6 +148,7 @@ mod thread_artifact; mod trace_credits; mod types; mod views; +mod web_push; pub use admin_configuration::{ ADMIN_CONFIGURATION_REPLACE_CAPABILITY, ADMIN_CONFIGURATION_REPLACE_CAPABILITY_ID, @@ -234,7 +235,9 @@ pub use ironclaw_product_contracts::product_wire::{ RebornSkillInfo, RebornSkillListResponse, RebornSkillSearchResponse, RebornSkillSourceKind, RebornSkillTrustLevel, RebornStreamEventsRequest, RebornStreamEventsResponse, RebornSubmitTurnResponse, RebornTimelineRequest, RebornTraceHoldAuthorizeProductRequest, - SettingsToolPermissionState, + RebornWebPushStatusResponse, RebornWebPushSubscribeOutcome, RebornWebPushSubscribeRequest, + RebornWebPushSubscribeResponse, RebornWebPushSubscriptionInfo, RebornWebPushUnsubscribeRequest, + RebornWebPushUnsubscribeResponse, SettingsToolPermissionState, }; // A product-tier port gets exactly one import path (§11.2.4), so this is a // private `use` and never a `pub use` — callers name the contracts crate. @@ -291,6 +294,15 @@ pub use types::{ RebornVendorAuthAccounts, }; pub use views::UnavailableRebornViewProvider; +pub use web_push::{ + RebornWebPushProductService, UnsupportedWebPushProductService, WebPushProductService, +}; +// The web-push descriptors live in `ironclaw_product_contracts::web_push` +// (transport/product boundary: transports consume the boundary crate). One +// import path, no re-export (§11.2.4). +use ironclaw_product_contracts::web_push::{ + WEB_PUSH_STATUS_VIEW, WEB_PUSH_SUBSCRIBE_COMMAND_ID, WEB_PUSH_UNSUBSCRIBE_COMMAND_ID, +}; type SkillActivationRecorder = dyn Fn(&TurnScope, &AcceptedMessageRef, &str) -> Result<(), ProductSurfaceError> + Send + Sync; @@ -2220,6 +2232,7 @@ pub struct RebornServices< channel_connection_service: Arc, channel_config_service: Option>, outbound_preferences_service: Arc, + web_push_service: Arc, operator_status: Arc, operator_logs: Arc, operator_service_lifecycle: Arc, @@ -2303,6 +2316,7 @@ where outbound_preferences_service: Arc::new( UnsupportedOutboundPreferencesProductService::new_static(), ), + web_push_service: Arc::new(UnsupportedWebPushProductService), operator_status: Arc::new(UnsupportedOperatorStatusService), operator_logs: Arc::new(UnsupportedOperatorLogsService), operator_service_lifecycle: Arc::new(UnsupportedOperatorServiceLifecycleService), @@ -2487,6 +2501,14 @@ where self } + pub fn with_web_push_product_service( + mut self, + web_push_service: Arc, + ) -> Self { + self.web_push_service = web_push_service; + self + } + async fn invoke_json_capability( &self, caller: ProductSurfaceCaller, @@ -4132,6 +4154,11 @@ where let response = self.build_notification_channels_view(caller).await?; views::view_page(response) } + id if id == WEB_PUSH_STATUS_VIEW.id => { + views::parse_empty_view_params(query.params)?; + let response = self.web_push_service.status(caller).await?; + views::view_page(response) + } id if id == TRACE_CREDITS_VIEW.id => { views::parse_empty_view_params(query.params)?; let response = self.build_trace_credits_view(caller).await?; diff --git a/crates/product/ironclaw_assistant/src/reborn_services/product_capability_handlers.rs b/crates/product/ironclaw_assistant/src/reborn_services/product_capability_handlers.rs index 27ab8f63300..247cd2a182a 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/product_capability_handlers.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/product_capability_handlers.rs @@ -36,6 +36,8 @@ pub(super) enum ProductCommandHandler { AutomationRename, AutomationDelete, NotificationChannelsSet, + WebPushSubscribe, + WebPushUnsubscribe, } impl ProductCommandHandler { @@ -74,6 +76,8 @@ impl ProductCommandHandler { AUTOMATION_RENAME_COMMAND_ID => Some(Self::AutomationRename), AUTOMATION_DELETE_COMMAND_ID => Some(Self::AutomationDelete), NOTIFICATION_CHANNELS_SET_COMMAND_ID => Some(Self::NotificationChannelsSet), + WEB_PUSH_SUBSCRIBE_COMMAND_ID => Some(Self::WebPushSubscribe), + WEB_PUSH_UNSUBSCRIBE_COMMAND_ID => Some(Self::WebPushUnsubscribe), _ => None, } } @@ -312,6 +316,19 @@ impl ProductCommandHandler { let request: RebornSetNotificationChannelsRequest = product_command_input(input)?; command_output(services.set_notification_channels(caller, request).await?) } + Self::WebPushSubscribe => { + let request: RebornWebPushSubscribeRequest = product_command_input(input)?; + command_output(services.web_push_service.subscribe(caller, request).await?) + } + Self::WebPushUnsubscribe => { + let request: RebornWebPushUnsubscribeRequest = product_command_input(input)?; + command_output( + services + .web_push_service + .unsubscribe(caller, request) + .await?, + ) + } } } } diff --git a/crates/product/ironclaw_assistant/src/reborn_services/web_push.rs b/crates/product/ironclaw_assistant/src/reborn_services/web_push.rs new file mode 100644 index 00000000000..b1df0c566f4 --- /dev/null +++ b/crates/product/ironclaw_assistant/src/reborn_services/web_push.rs @@ -0,0 +1,231 @@ +//! Web-push enrollment product surface: the status view (VAPID public key + +//! enrolled browsers) and the subscribe/unsubscribe product commands the +//! WebUI settings panel drives. +//! +//! Like `set_notification_channels`, both writes are direct authenticated +//! user settings mutations with no approval-gate need, dispatched through +//! [`super::product_capability_handlers::ProductCommandHandler`]. Push +//! *delivery* is not here — the web-push channel adapter owns it behind the +//! delivery coordinator. + +use std::sync::Arc; + +use async_trait::async_trait; +use ironclaw_host_api::ids::InvocationId; +use ironclaw_host_api::resource::ResourceScope; +use ironclaw_web_push::{ + PushEndpoint, PushSubscriptionKeys, PushSubscriptionRecord, PushSubscriptionUpsertOutcome, + WebPushError, WebPushSubscriptionStore, +}; + +use super::{ + ProductSurfaceCaller, ProductSurfaceError, RebornWebPushStatusResponse, + RebornWebPushSubscribeOutcome, RebornWebPushSubscribeRequest, RebornWebPushSubscribeResponse, + RebornWebPushSubscriptionInfo, RebornWebPushUnsubscribeRequest, + RebornWebPushUnsubscribeResponse, +}; + +/// Web-push enrollment operations for the authenticated caller. +#[async_trait] +pub trait WebPushProductService: Send + Sync { + async fn status( + &self, + caller: ProductSurfaceCaller, + ) -> Result; + + async fn subscribe( + &self, + caller: ProductSurfaceCaller, + request: RebornWebPushSubscribeRequest, + ) -> Result; + + async fn unsubscribe( + &self, + caller: ProductSurfaceCaller, + request: RebornWebPushUnsubscribeRequest, + ) -> Result; +} + +/// Fail-closed default until composition wires the real service. +pub struct UnsupportedWebPushProductService; + +#[async_trait] +impl WebPushProductService for UnsupportedWebPushProductService { + async fn status( + &self, + _caller: ProductSurfaceCaller, + ) -> Result { + Err(web_push_unavailable()) + } + + async fn subscribe( + &self, + _caller: ProductSurfaceCaller, + _request: RebornWebPushSubscribeRequest, + ) -> Result { + Err(web_push_unavailable()) + } + + async fn unsubscribe( + &self, + _caller: ProductSurfaceCaller, + _request: RebornWebPushUnsubscribeRequest, + ) -> Result { + Err(web_push_unavailable()) + } +} + +/// Production service over the web-push subscription store. +pub struct RebornWebPushProductService { + subscriptions: Arc, + vapid_public_key: String, + /// Push-service hosts enrollments may target — read from the web-push + /// manifest's `[[channel.egress]]` declarations at composition, the same + /// list restricted egress enforces at send time. + allowed_push_hosts: Vec, +} + +impl RebornWebPushProductService { + pub fn new( + subscriptions: Arc, + vapid_public_key: String, + allowed_push_hosts: Vec, + ) -> Self { + Self { + subscriptions, + vapid_public_key, + allowed_push_hosts, + } + } + + /// Invariant: the scope is derived from the authenticated caller, never + /// from request-body tenant/user fields. + fn scope(caller: &ProductSurfaceCaller) -> ResourceScope { + ResourceScope { + tenant_id: caller.tenant_id.clone(), + user_id: caller.user_id.clone(), + agent_id: None, + project_id: None, + mission_id: None, + thread_id: None, + invocation_id: InvocationId::new(), + } + } +} + +#[async_trait] +impl WebPushProductService for RebornWebPushProductService { + async fn status( + &self, + caller: ProductSurfaceCaller, + ) -> Result { + let scope = Self::scope(&caller); + let subscriptions = self + .subscriptions + .list_subscriptions(&scope) + .await + .map_err(map_web_push_error)?; + let infos = subscriptions + .iter() + .map(|record| { + Ok(RebornWebPushSubscriptionInfo { + subscription_id: record.subscription_id.clone(), + endpoint_host: record.endpoint.host().map_err(map_web_push_error)?, + user_agent: record.user_agent.clone(), + created_at: record.created_at.clone(), + }) + }) + .collect::, ProductSurfaceError>>()?; + Ok(RebornWebPushStatusResponse { + vapid_public_key: self.vapid_public_key.clone(), + subscription_count: u32::try_from(infos.len()).unwrap_or(u32::MAX), + subscriptions: infos, + }) + } + + async fn subscribe( + &self, + caller: ProductSurfaceCaller, + request: RebornWebPushSubscribeRequest, + ) -> Result { + let endpoint = PushEndpoint::new(request.endpoint).map_err(map_web_push_error)?; + endpoint + .validate_against_push_services(&self.allowed_push_hosts) + .map_err(map_web_push_error)?; + let keys = PushSubscriptionKeys::new(request.keys.p256dh, request.keys.auth) + .map_err(map_web_push_error)?; + let record = PushSubscriptionRecord::new( + endpoint, + keys, + request.user_agent, + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + ); + let scope = Self::scope(&caller); + let outcome = self + .subscriptions + .upsert_subscription(&scope, record) + .await + .map_err(map_web_push_error)?; + Ok(RebornWebPushSubscribeResponse { + outcome: match outcome { + PushSubscriptionUpsertOutcome::Enrolled => RebornWebPushSubscribeOutcome::Enrolled, + PushSubscriptionUpsertOutcome::Refreshed => { + RebornWebPushSubscribeOutcome::Refreshed + } + }, + }) + } + + async fn unsubscribe( + &self, + caller: ProductSurfaceCaller, + request: RebornWebPushUnsubscribeRequest, + ) -> Result { + let endpoint = PushEndpoint::new(request.endpoint).map_err(map_web_push_error)?; + let scope = Self::scope(&caller); + let removed = self + .subscriptions + .remove_subscription(&scope, &endpoint) + .await + .map_err(map_web_push_error)?; + Ok(RebornWebPushUnsubscribeResponse { removed }) + } +} + +pub(super) fn web_push_unavailable() -> ProductSurfaceError { + ProductSurfaceError::service_unavailable(false) +} + +/// Map domain failures onto the sanitized product taxonomy. Caller-correctable +/// input problems become validation errors; storage trouble stays a retryable +/// service failure. Endpoint URLs never enter the payload — the domain error +/// carries hosts only. +fn map_web_push_error(error: WebPushError) -> ProductSurfaceError { + match &error { + WebPushError::InvalidSubscription { .. } | WebPushError::UnsupportedPushService { .. } => { + ProductSurfaceError::validation( + "subscription", + super::ProductSurfaceValidationCode::InvalidValue, + ) + } + WebPushError::SubscriptionLimitReached { .. } => ProductSurfaceError::validation( + "subscription", + super::ProductSurfaceValidationCode::TooLong, + ), + WebPushError::InvalidScope { .. } => { + tracing::error!(%error, "web push scope projection failed"); + ProductSurfaceError::internal_from(error) + } + WebPushError::Store { .. } => { + tracing::warn!(%error, "web push subscription store failure"); + ProductSurfaceError::service_unavailable(true) + } + WebPushError::RuntimeUnavailable | WebPushError::RuntimeAlreadyInstalled => { + ProductSurfaceError::service_unavailable(false) + } + WebPushError::PayloadTooLarge { .. } | WebPushError::Crypto { .. } => { + tracing::error!(%error, "unexpected web push failure on the enrollment surface"); + ProductSurfaceError::internal_from(error) + } + } +} diff --git a/crates/product/ironclaw_webui/CONTRACT.md b/crates/product/ironclaw_webui/CONTRACT.md index 62dfd95fb9a..97a2367075a 100644 --- a/crates/product/ironclaw_webui/CONTRACT.md +++ b/crates/product/ironclaw_webui/CONTRACT.md @@ -140,7 +140,7 @@ candidate module. | `commands` | The product command surface: listing and executing | A command *constant* — those are `ironclaw_assistant`'s frozen inventory | `list_commands`, `ExecuteCommandBody`, `execute_command` | | `automations` | Automation listing and lifecycle (pause/resume/rename/delete) | Trigger evaluation — that is the triggers domain | `list_automations`, `pause_automation`, `resume_automation`, `rename_automation`, `delete_automation`, `ListAutomationsQuery` | | `traces` | Trace credits, account traces, the account login link, and hold authorization | Trace *content* — that is `ironclaw_trace_commons` | `trace_credits`, `trace_account_traces`, `trace_account_login_link`, `authorize_trace_hold` | -| `outbound` | Outbound notification channels, delivery targets, and the capability-failure→HTTP classification they introduced | Delivery itself — the host owns the coordinator | `get_notification_channels`, `set_notification_channels`, `CapabilityFailureHttpClass`, `capability_failure_http_class`, `capability_failure_bad_request`, `capability_resolution_succeeded`, `parse_thread_id_for_response`, `outbound_preferences_forbidden`, `outbound_preferences_unavailable`, `list_outbound_delivery_targets` | +| `outbound` | Outbound notification channels, delivery targets, web-push enrollment, and the capability-failure→HTTP classification they introduced | Delivery itself — the host owns the coordinator | `get_notification_channels`, `set_notification_channels`, `CapabilityFailureHttpClass`, `capability_failure_http_class`, `capability_failure_bad_request`, `capability_resolution_succeeded`, `parse_thread_id_for_response`, `outbound_preferences_forbidden`, `outbound_preferences_unavailable`, `list_outbound_delivery_targets`, `web_push_status`, `web_push_subscribe`, `web_push_unsubscribe` | | `skills` | Skill discovery, install/update/remove, content reads, and auto-activation | Skill *selection* — that is `ironclaw_skills` | `list_skills`, `search_skills`, `install_skill`, `get_skill_content`, `update_skill`, `remove_skill`, `set_skill_auto_activate`, `set_auto_activate_learned`, `skill_mutation_succeeded`, `skill_mutation_forbidden`, `skill_mutation_unavailable`, `SkillPath`, `SearchSkillsBody`, `InstallSkillBody`, `UpdateSkillBody`, `SetSkillAutoActivateBody` | | `extensions` | Extension listing, registry browse, install/import/remove, hosted-MCP registration, the setup handshake, and the lifecycle response projections | Admin *configuration* of an installed extension — that is `admin-config` | `list_extensions`, `list_extension_registry`, `install_extension`, `register_hosted_mcp_extension`, `import_extension`, `ironhub_deliver_install`, `remove_extension`, `extension_lifecycle_mutation_succeeded`, `extension_install_succeeded`, `membership_is_visible`, `membership_landed_pending_setup`, `ensure_extension_inventory_readback`, `extension_lifecycle_forbidden`, `extension_lifecycle_unavailable`, `extension_action_completed`, `get_extension_setup`, `setup_extension`, `public_lifecycle_json`, `extension_lifecycle_activity_id`, `ExtensionPackagePath`, `InstallExtensionBody`, `RegisterHostedMcpBody`, `RegisterHostedMcpResponse`, `bounded_hosted_mcp_name`, `RemoveExtensionBody`, `extension_package_ref_for_request` | | `admin-config` | Per-extension admin configuration: read, replace, idempotency, and its failure projections | Extension lifecycle — that is `extensions` | `ADMIN_CONFIGURATION_IDEMPOTENCY_KEY_MAX_BYTES`, `require_operator_webui_config`, `ExtensionAdminConfigurationPath`, `ExtensionAdminConfigurationValue`, `ReplaceExtensionAdminConfigurationBody`, `ReplaceExtensionAdminConfigurationInput`, `list_extension_admin_configuration`, `replace_extension_admin_configuration`, `query_extension_admin_configuration`, `select_extension_admin_configuration_group`, `admin_configuration_activity_id`, `admin_configuration_conflict`, `admin_configuration_unavailable`, `admin_configuration_forbidden`, `admin_configuration_done_failure`, `admin_configuration_blocked` | @@ -201,6 +201,8 @@ closed (`500`) if that layer is missing (locked by | `webui.v2.operator.inspector_*` | GET | `/api/webchat/v2/operator/inspector/threads/{thread_id}/runs/{run_id}[/prompt\|/tools/{activity_id}\|/events]` | `events`: SSE; others — | `ProjectionOnly` | | `webui.v2.admin.*` (users CRUD, status, role, secrets) | GET/POST/PATCH/PUT/DELETE | `/api/webchat/v2/admin/users…` | — | `ProductSurface` | | `webui.v2.trace_*` (credit, account, account-login-link, holds/authorize) | GET/POST | `/api/webchat/v2/traces/…` | — | `ProductSurface` | +| `webui.v2.web_push_status` | GET | `/api/webchat/v2/web-push/status` | — | `ProductSurface` | +| `webui.v2.web_push_subscribe` / `web_push_unsubscribe` | POST | `/api/webchat/v2/web-push/subscriptions[/remove]` | — | `ProductSurface` | The exact per-route set (methods, query params, auth, rate/body limits) is the descriptor table in `src/webui_v2/descriptors.rs`; the count/shape is locked by diff --git a/crates/product/ironclaw_webui/frontend/public/sw.js b/crates/product/ironclaw_webui/frontend/public/sw.js new file mode 100644 index 00000000000..8da5ef6b028 --- /dev/null +++ b/crates/product/ironclaw_webui/frontend/public/sw.js @@ -0,0 +1,78 @@ +// IronClaw service worker: Web Push display + notification deep links. +// +// The push payload contract is `ironclaw_web_push::WebPushNotificationPayload` +// — `{ title, body, url, tag? }` — encrypted per RFC 8291; the browser hands +// this worker the decrypted JSON. Deliberately NO fetch handler and NO +// caching: the app is served fresh by the gateway, and a stale-asset cache +// is a worse failure mode than a network round-trip. Installability no +// longer requires offline support in current browsers. + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +function payloadFromEvent(event) { + const fallback = { + title: "IronClaw", + body: "You have a new notification.", + url: "/", + }; + if (!event.data) return fallback; + try { + const parsed = event.data.json(); + if (!parsed || typeof parsed !== "object") return fallback; + return { + title: typeof parsed.title === "string" && parsed.title ? parsed.title : fallback.title, + body: typeof parsed.body === "string" ? parsed.body : fallback.body, + url: typeof parsed.url === "string" && parsed.url ? parsed.url : fallback.url, + tag: typeof parsed.tag === "string" && parsed.tag ? parsed.tag : undefined, + }; + } catch (_) { + return fallback; + } +} + +self.addEventListener("push", (event) => { + const payload = payloadFromEvent(event); + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + tag: payload.tag, + data: { url: payload.url }, + icon: "/assets/web-app-manifest-192x192.png", + badge: "/assets/web-app-manifest-192x192.png", + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = (event.notification.data && event.notification.data.url) || "/"; + event.waitUntil( + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((clientList) => { + for (const client of clientList) { + try { + const clientOrigin = new URL(client.url).origin; + if (clientOrigin === self.location.origin && "focus" in client) { + if ("navigate" in client) { + return client.navigate(url).then((navigated) => (navigated || client).focus()); + } + return client.focus(); + } + } catch (_) { + // Ignore unparseable client URLs and keep looking. + } + } + if (self.clients.openWindow) { + return self.clients.openWindow(url); + } + return undefined; + }), + ); +}); diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts index 2dd8098e2c4..249adcb75bf 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts @@ -794,7 +794,17 @@ registerPack("ar", { "automations.notificationChannels.explainer": "اختر القنوات المتصلة التي تتلقى طلبات الموافقة وطلبات المصادقة وإشعارات فشل التشغيل.", "automations.notificationChannels.empty": "لا توجد قنوات متصلة بعد.", - "automations.notificationChannels.webOnlyHelper": "تبقى الإشعارات في تطبيق الويب", + "automations.notificationChannels.webOnlyHelper": "لم يتم اختيار أي قناة إشعارات — لن يتم تسليم طلبات الموافقة وتنبيهات المصادقة وإشعارات الفشل إلى أي وجهة.", + "automations.notificationChannels.webPush.deviceHeading": "هذا المتصفح", + "automations.notificationChannels.webPush.checking": "جارٍ التحقق من دعم الإشعارات في هذا المتصفح…", + "automations.notificationChannels.webPush.unsupported": "هذا المتصفح لا يدعم الإشعارات الفورية.", + "automations.notificationChannels.webPush.permissionDenied": "الإشعارات محظورة لهذا الموقع. اسمح بها في إعدادات الموقع في متصفحك ثم حاول مرة أخرى.", + "automations.notificationChannels.webPush.notEnrolled": "هذا المتصفح لا يتلقى الإشعارات بعد.", + "automations.notificationChannels.webPush.enrolled": "هذا المتصفح يتلقى الإشعارات.", + "automations.notificationChannels.webPush.enroll": "تفعيل الإشعارات في هذا المتصفح", + "automations.notificationChannels.webPush.unenroll": "التعطيل في هذا المتصفح", + "automations.notificationChannels.webPush.deviceCount": "المتصفحات المسجلة: {count}", + "automations.notificationChannels.webPush.actionFailed": "تعذر تحديث تسجيل الإشعارات لهذا المتصفح. يرجى المحاولة مرة أخرى.", "automations.notificationChannels.save": "حفظ", "automations.notificationChannels.saved": "تم الحفظ", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/de.ts b/crates/product/ironclaw_webui/frontend/src/i18n/de.ts index 0da234dea7f..98d63503cd7 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/de.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/de.ts @@ -794,7 +794,17 @@ registerPack("de", { "automations.notificationChannels.explainer": "Wähle, welche verbundenen Kanäle Freigabe-Anfragen, Anmelde-Aufforderungen und Hinweise zu fehlgeschlagenen Läufen erhalten.", "automations.notificationChannels.empty": "Noch keine verbundenen Kanäle.", - "automations.notificationChannels.webOnlyHelper": "Benachrichtigungen bleiben in der Web-App", + "automations.notificationChannels.webOnlyHelper": "Kein Benachrichtigungskanal ausgewählt — Freigabe-Anfragen, Anmelde-Aufforderungen und Fehlermeldungen werden nirgendwohin zugestellt.", + "automations.notificationChannels.webPush.deviceHeading": "Dieser Browser", + "automations.notificationChannels.webPush.checking": "Benachrichtigungsunterstützung dieses Browsers wird geprüft…", + "automations.notificationChannels.webPush.unsupported": "Dieser Browser unterstützt keine Push-Benachrichtigungen.", + "automations.notificationChannels.webPush.permissionDenied": "Benachrichtigungen sind für diese Website blockiert. Erlaube sie in den Website-Einstellungen deines Browsers und versuche es erneut.", + "automations.notificationChannels.webPush.notEnrolled": "Dieser Browser empfängt noch keine Benachrichtigungen.", + "automations.notificationChannels.webPush.enrolled": "Dieser Browser empfängt Benachrichtigungen.", + "automations.notificationChannels.webPush.enroll": "Benachrichtigungen in diesem Browser aktivieren", + "automations.notificationChannels.webPush.unenroll": "In diesem Browser deaktivieren", + "automations.notificationChannels.webPush.deviceCount": "Registrierte Browser: {count}", + "automations.notificationChannels.webPush.actionFailed": "Die Benachrichtigungsregistrierung dieses Browsers konnte nicht geändert werden. Bitte versuche es erneut.", "automations.notificationChannels.save": "Speichern", "automations.notificationChannels.saved": "Gespeichert", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/en.ts b/crates/product/ironclaw_webui/frontend/src/i18n/en.ts index 4a0f570989b..120501b533c 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/en.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/en.ts @@ -843,7 +843,17 @@ registerPack("en", { "automations.notificationChannels.explainer": "Choose which connected channels receive approval prompts, auth prompts, and run-failure notices.", "automations.notificationChannels.empty": "No connected channels yet.", - "automations.notificationChannels.webOnlyHelper": "Notifications stay in the web app", + "automations.notificationChannels.webOnlyHelper": "No notification channel is selected — approval prompts, auth prompts, and failure notices won't be delivered anywhere.", + "automations.notificationChannels.webPush.deviceHeading": "This browser", + "automations.notificationChannels.webPush.checking": "Checking this browser's notification support…", + "automations.notificationChannels.webPush.unsupported": "This browser doesn't support push notifications.", + "automations.notificationChannels.webPush.permissionDenied": "Notifications are blocked for this site. Allow them in your browser's site settings, then try again.", + "automations.notificationChannels.webPush.notEnrolled": "This browser isn't receiving notifications yet.", + "automations.notificationChannels.webPush.enrolled": "This browser receives notifications.", + "automations.notificationChannels.webPush.enroll": "Enable notifications in this browser", + "automations.notificationChannels.webPush.unenroll": "Disable in this browser", + "automations.notificationChannels.webPush.deviceCount": "Enrolled browsers: {count}", + "automations.notificationChannels.webPush.actionFailed": "Couldn't update this browser's notification enrollment. Please try again.", "automations.notificationChannels.save": "Save", "automations.notificationChannels.saved": "Saved", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/es.ts b/crates/product/ironclaw_webui/frontend/src/i18n/es.ts index dcad5dcd6e0..ffa7f7a262c 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/es.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/es.ts @@ -794,7 +794,17 @@ registerPack("es", { "automations.notificationChannels.explainer": "Elige qué canales conectados reciben solicitudes de aprobación, solicitudes de autenticación y avisos de ejecuciones fallidas.", "automations.notificationChannels.empty": "Aún no hay canales conectados.", - "automations.notificationChannels.webOnlyHelper": "Las notificaciones se quedan en la aplicación web", + "automations.notificationChannels.webOnlyHelper": "No hay ningún canal de notificaciones seleccionado: las solicitudes de aprobación, los avisos de autenticación y los fallos no se entregarán a ningún destino.", + "automations.notificationChannels.webPush.deviceHeading": "Este navegador", + "automations.notificationChannels.webPush.checking": "Comprobando la compatibilidad de notificaciones de este navegador…", + "automations.notificationChannels.webPush.unsupported": "Este navegador no admite notificaciones push.", + "automations.notificationChannels.webPush.permissionDenied": "Las notificaciones están bloqueadas para este sitio. Permítelas en la configuración del sitio de tu navegador e inténtalo de nuevo.", + "automations.notificationChannels.webPush.notEnrolled": "Este navegador aún no recibe notificaciones.", + "automations.notificationChannels.webPush.enrolled": "Este navegador recibe notificaciones.", + "automations.notificationChannels.webPush.enroll": "Activar notificaciones en este navegador", + "automations.notificationChannels.webPush.unenroll": "Desactivar en este navegador", + "automations.notificationChannels.webPush.deviceCount": "Navegadores registrados: {count}", + "automations.notificationChannels.webPush.actionFailed": "No se pudo actualizar el registro de notificaciones de este navegador. Inténtalo de nuevo.", "automations.notificationChannels.save": "Guardar", "automations.notificationChannels.saved": "Guardado", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts b/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts index abf9c5b86a3..c4a889e00d0 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts @@ -794,7 +794,17 @@ registerPack("fr", { "automations.notificationChannels.explainer": "Choisissez quels canaux connectés reçoivent les demandes d'approbation, les invites d'authentification et les avis d'échec d'exécution.", "automations.notificationChannels.empty": "Aucun canal connecté pour le moment.", - "automations.notificationChannels.webOnlyHelper": "Les notifications restent dans l'application web", + "automations.notificationChannels.webOnlyHelper": "Aucun canal de notification n'est sélectionné — les demandes d'approbation, les invites d'authentification et les avis d'échec ne seront livrés nulle part.", + "automations.notificationChannels.webPush.deviceHeading": "Ce navigateur", + "automations.notificationChannels.webPush.checking": "Vérification de la prise en charge des notifications par ce navigateur…", + "automations.notificationChannels.webPush.unsupported": "Ce navigateur ne prend pas en charge les notifications push.", + "automations.notificationChannels.webPush.permissionDenied": "Les notifications sont bloquées pour ce site. Autorisez-les dans les paramètres du site de votre navigateur, puis réessayez.", + "automations.notificationChannels.webPush.notEnrolled": "Ce navigateur ne reçoit pas encore de notifications.", + "automations.notificationChannels.webPush.enrolled": "Ce navigateur reçoit les notifications.", + "automations.notificationChannels.webPush.enroll": "Activer les notifications dans ce navigateur", + "automations.notificationChannels.webPush.unenroll": "Désactiver dans ce navigateur", + "automations.notificationChannels.webPush.deviceCount": "Navigateurs inscrits : {count}", + "automations.notificationChannels.webPush.actionFailed": "Impossible de mettre à jour l'inscription aux notifications de ce navigateur. Veuillez réessayer.", "automations.notificationChannels.save": "Enregistrer", "automations.notificationChannels.saved": "Enregistré", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts b/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts index 2c4f239fc84..14d93e9644e 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts @@ -794,7 +794,17 @@ registerPack("hi", { "automations.notificationChannels.explainer": "चुनें कि कौन से कनेक्टेड चैनल अनुमोदन अनुरोध, प्रमाणीकरण अनुरोध और रन विफलता की सूचनाएँ प्राप्त करें।", "automations.notificationChannels.empty": "अभी कोई कनेक्टेड चैनल नहीं है।", - "automations.notificationChannels.webOnlyHelper": "सूचनाएँ वेब ऐप में ही रहती हैं", + "automations.notificationChannels.webOnlyHelper": "कोई सूचना चैनल चयनित नहीं है — अनुमोदन अनुरोध, प्रमाणीकरण संकेत और विफलता सूचनाएँ कहीं नहीं पहुँचाई जाएँगी।", + "automations.notificationChannels.webPush.deviceHeading": "यह ब्राउज़र", + "automations.notificationChannels.webPush.checking": "इस ब्राउज़र में सूचना समर्थन की जाँच हो रही है…", + "automations.notificationChannels.webPush.unsupported": "यह ब्राउज़र पुश सूचनाओं का समर्थन नहीं करता।", + "automations.notificationChannels.webPush.permissionDenied": "इस साइट के लिए सूचनाएँ अवरुद्ध हैं। अपने ब्राउज़र की साइट सेटिंग में उन्हें अनुमति दें, फिर पुनः प्रयास करें।", + "automations.notificationChannels.webPush.notEnrolled": "यह ब्राउज़र अभी सूचनाएँ प्राप्त नहीं कर रहा है।", + "automations.notificationChannels.webPush.enrolled": "यह ब्राउज़र सूचनाएँ प्राप्त करता है।", + "automations.notificationChannels.webPush.enroll": "इस ब्राउज़र में सूचनाएँ सक्षम करें", + "automations.notificationChannels.webPush.unenroll": "इस ब्राउज़र में अक्षम करें", + "automations.notificationChannels.webPush.deviceCount": "पंजीकृत ब्राउज़र: {count}", + "automations.notificationChannels.webPush.actionFailed": "इस ब्राउज़र का सूचना पंजीकरण अपडेट नहीं हो सका। कृपया पुनः प्रयास करें।", "automations.notificationChannels.save": "सहेजें", "automations.notificationChannels.saved": "सहेजा गया", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts index 10fb1a68763..d51c6eb4671 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts @@ -794,7 +794,17 @@ registerPack("ja", { "automations.notificationChannels.explainer": "承認リクエスト、認証リクエスト、実行失敗の通知を受け取る接続済みチャネルを選択します。", "automations.notificationChannels.empty": "接続済みのチャネルはまだありません。", - "automations.notificationChannels.webOnlyHelper": "通知はウェブアプリ内に留まります", + "automations.notificationChannels.webOnlyHelper": "通知チャネルが選択されていません — 承認リクエスト、認証プロンプト、失敗通知はどこにも配信されません。", + "automations.notificationChannels.webPush.deviceHeading": "このブラウザ", + "automations.notificationChannels.webPush.checking": "このブラウザの通知サポートを確認しています…", + "automations.notificationChannels.webPush.unsupported": "このブラウザはプッシュ通知に対応していません。", + "automations.notificationChannels.webPush.permissionDenied": "このサイトの通知がブロックされています。ブラウザのサイト設定で通知を許可してから、もう一度お試しください。", + "automations.notificationChannels.webPush.notEnrolled": "このブラウザはまだ通知を受信していません。", + "automations.notificationChannels.webPush.enrolled": "このブラウザは通知を受信します。", + "automations.notificationChannels.webPush.enroll": "このブラウザで通知を有効にする", + "automations.notificationChannels.webPush.unenroll": "このブラウザで無効にする", + "automations.notificationChannels.webPush.deviceCount": "登録済みブラウザ: {count}", + "automations.notificationChannels.webPush.actionFailed": "このブラウザの通知登録を更新できませんでした。もう一度お試しください。", "automations.notificationChannels.save": "保存", "automations.notificationChannels.saved": "保存しました", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts index 92ca9953366..8ba19b30676 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts @@ -794,7 +794,17 @@ registerPack("ko", { "automations.notificationChannels.explainer": "승인 요청, 인증 요청, 실행 실패 알림을 받을 연결된 채널을 선택하세요.", "automations.notificationChannels.empty": "아직 연결된 채널이 없습니다.", - "automations.notificationChannels.webOnlyHelper": "알림은 웹 앱에만 표시됩니다", + "automations.notificationChannels.webOnlyHelper": "선택된 알림 채널이 없습니다 — 승인 요청, 인증 안내, 실패 알림이 어디에도 전달되지 않습니다.", + "automations.notificationChannels.webPush.deviceHeading": "이 브라우저", + "automations.notificationChannels.webPush.checking": "이 브라우저의 알림 지원을 확인하는 중…", + "automations.notificationChannels.webPush.unsupported": "이 브라우저는 푸시 알림을 지원하지 않습니다.", + "automations.notificationChannels.webPush.permissionDenied": "이 사이트의 알림이 차단되어 있습니다. 브라우저의 사이트 설정에서 알림을 허용한 후 다시 시도하세요.", + "automations.notificationChannels.webPush.notEnrolled": "이 브라우저는 아직 알림을 받지 않습니다.", + "automations.notificationChannels.webPush.enrolled": "이 브라우저는 알림을 받습니다.", + "automations.notificationChannels.webPush.enroll": "이 브라우저에서 알림 사용", + "automations.notificationChannels.webPush.unenroll": "이 브라우저에서 사용 안 함", + "automations.notificationChannels.webPush.deviceCount": "등록된 브라우저: {count}", + "automations.notificationChannels.webPush.actionFailed": "이 브라우저의 알림 등록을 업데이트하지 못했습니다. 다시 시도해 주세요.", "automations.notificationChannels.save": "저장", "automations.notificationChannels.saved": "저장됨", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts b/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts index 841eb603b32..2d3eee48cde 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts @@ -794,7 +794,17 @@ registerPack("pt-BR", { "automations.notificationChannels.explainer": "Escolha quais canais conectados recebem pedidos de aprovação, solicitações de autenticação e avisos de falha de execução.", "automations.notificationChannels.empty": "Ainda não há canais conectados.", - "automations.notificationChannels.webOnlyHelper": "As notificações ficam no aplicativo web", + "automations.notificationChannels.webOnlyHelper": "Nenhum canal de notificação está selecionado — solicitações de aprovação, avisos de autenticação e notificações de falha não serão entregues em lugar algum.", + "automations.notificationChannels.webPush.deviceHeading": "Este navegador", + "automations.notificationChannels.webPush.checking": "Verificando o suporte a notificações deste navegador…", + "automations.notificationChannels.webPush.unsupported": "Este navegador não oferece suporte a notificações push.", + "automations.notificationChannels.webPush.permissionDenied": "As notificações estão bloqueadas para este site. Permita-as nas configurações do site do seu navegador e tente novamente.", + "automations.notificationChannels.webPush.notEnrolled": "Este navegador ainda não recebe notificações.", + "automations.notificationChannels.webPush.enrolled": "Este navegador recebe notificações.", + "automations.notificationChannels.webPush.enroll": "Ativar notificações neste navegador", + "automations.notificationChannels.webPush.unenroll": "Desativar neste navegador", + "automations.notificationChannels.webPush.deviceCount": "Navegadores registrados: {count}", + "automations.notificationChannels.webPush.actionFailed": "Não foi possível atualizar o registro de notificações deste navegador. Tente novamente.", "automations.notificationChannels.save": "Salvar", "automations.notificationChannels.saved": "Salvo", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts b/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts index 892ce191571..20209ac9eed 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts @@ -794,7 +794,17 @@ registerPack("uk", { "automations.notificationChannels.explainer": "Виберіть, які підключені канали отримуватимуть запити на схвалення, запити автентифікації та сповіщення про невдалі запуски.", "automations.notificationChannels.empty": "Підключених каналів поки немає.", - "automations.notificationChannels.webOnlyHelper": "Сповіщення залишаються у вебзастосунку", + "automations.notificationChannels.webOnlyHelper": "Канал сповіщень не вибрано — запити на схвалення, підказки автентифікації та сповіщення про збої нікуди не доставлятимуться.", + "automations.notificationChannels.webPush.deviceHeading": "Цей браузер", + "automations.notificationChannels.webPush.checking": "Перевіряємо підтримку сповіщень у цьому браузері…", + "automations.notificationChannels.webPush.unsupported": "Цей браузер не підтримує push-сповіщення.", + "automations.notificationChannels.webPush.permissionDenied": "Сповіщення для цього сайту заблоковано. Дозвольте їх у налаштуваннях сайту вашого браузера й спробуйте ще раз.", + "automations.notificationChannels.webPush.notEnrolled": "Цей браузер ще не отримує сповіщень.", + "automations.notificationChannels.webPush.enrolled": "Цей браузер отримує сповіщення.", + "automations.notificationChannels.webPush.enroll": "Увімкнути сповіщення в цьому браузері", + "automations.notificationChannels.webPush.unenroll": "Вимкнути в цьому браузері", + "automations.notificationChannels.webPush.deviceCount": "Зареєстровані браузери: {count}", + "automations.notificationChannels.webPush.actionFailed": "Не вдалося оновити реєстрацію сповіщень цього браузера. Спробуйте ще раз.", "automations.notificationChannels.save": "Зберегти", "automations.notificationChannels.saved": "Збережено", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts b/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts index 3a96706d46e..13458dca567 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts @@ -793,7 +793,17 @@ registerPack("zh-CN", { "automations.notificationChannels.explainer": "选择哪些已连接的渠道接收审批请求、认证请求和运行失败通知。", "automations.notificationChannels.empty": "暂无已连接的渠道。", - "automations.notificationChannels.webOnlyHelper": "通知仅保留在网页应用中", + "automations.notificationChannels.webOnlyHelper": "未选择任何通知渠道——审批请求、认证提示和失败通知将不会送达任何地方。", + "automations.notificationChannels.webPush.deviceHeading": "此浏览器", + "automations.notificationChannels.webPush.checking": "正在检查此浏览器的通知支持…", + "automations.notificationChannels.webPush.unsupported": "此浏览器不支持推送通知。", + "automations.notificationChannels.webPush.permissionDenied": "此站点的通知已被屏蔽。请在浏览器的站点设置中允许通知,然后重试。", + "automations.notificationChannels.webPush.notEnrolled": "此浏览器尚未接收通知。", + "automations.notificationChannels.webPush.enrolled": "此浏览器会接收通知。", + "automations.notificationChannels.webPush.enroll": "在此浏览器中启用通知", + "automations.notificationChannels.webPush.unenroll": "在此浏览器中停用", + "automations.notificationChannels.webPush.deviceCount": "已注册的浏览器:{count}", + "automations.notificationChannels.webPush.actionFailed": "无法更新此浏览器的通知注册。请重试。", "automations.notificationChannels.save": "保存", "automations.notificationChannels.saved": "已保存", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/lib/api.test.ts b/crates/product/ironclaw_webui/frontend/src/lib/api.test.ts index 6ff96e19323..9d01c1489e2 100644 --- a/crates/product/ironclaw_webui/frontend/src/lib/api.test.ts +++ b/crates/product/ironclaw_webui/frontend/src/lib/api.test.ts @@ -17,8 +17,11 @@ import { queryOperatorLogs, renameAutomation, resumeAutomation, + getWebPushStatus, setNotificationChannels, setupExtension, + subscribeWebPush, + unsubscribeWebPush, } from "./api"; const originalFetch = globalThis.fetch; @@ -602,3 +605,88 @@ test("clientActionId falls back when global crypto is null", () => { assert.notEqual(id, "0".repeat(32)); }); }); + +test("getWebPushStatus reads the web-push status route", async () => { + const calls = []; + globalThis.sessionStorage = { + getItem: () => "", + setItem: () => {}, + removeItem: () => {}, + }; + globalThis.fetch = async (path, options) => { + calls.push({ path, options }); + return new Response( + JSON.stringify({ vapid_public_key: "k", subscription_count: 0, subscriptions: [] }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + + const status = await getWebPushStatus(); + + assert.equal(calls.length, 1); + assert.equal(calls[0].path, "/api/webchat/v2/web-push/status"); + assert.equal(status.vapid_public_key, "k"); +}); + +test("subscribeWebPush posts the subscription wire body and validates required keys", async () => { + const calls = []; + globalThis.sessionStorage = { + getItem: () => "", + setItem: () => {}, + removeItem: () => {}, + }; + globalThis.fetch = async (path, options) => { + calls.push({ path, options }); + return new Response(JSON.stringify({ outcome: "enrolled" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + await subscribeWebPush({ + endpoint: "https://fcm.googleapis.com/fcm/send/abc", + keys: { p256dh: "pk", auth: "as" }, + userAgent: "TestBrowser/1.0", + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].path, "/api/webchat/v2/web-push/subscriptions"); + assert.equal(calls[0].options.method, "POST"); + assert.deepEqual(JSON.parse(calls[0].options.body), { + endpoint: "https://fcm.googleapis.com/fcm/send/abc", + keys: { p256dh: "pk", auth: "as" }, + user_agent: "TestBrowser/1.0", + }); + + await assert.rejects(subscribeWebPush(), /endpoint is required/); + await assert.rejects( + subscribeWebPush({ endpoint: "https://fcm.googleapis.com/fcm/send/abc" }), + /keys\.p256dh and keys\.auth are required/, + ); + assert.equal(calls.length, 1, "invalid input must never reach fetch"); +}); + +test("unsubscribeWebPush posts the endpoint removal body", async () => { + const calls = []; + globalThis.sessionStorage = { + getItem: () => "", + setItem: () => {}, + removeItem: () => {}, + }; + globalThis.fetch = async (path, options) => { + calls.push({ path, options }); + return new Response(JSON.stringify({ removed: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + await unsubscribeWebPush({ endpoint: "https://fcm.googleapis.com/fcm/send/abc" }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].path, "/api/webchat/v2/web-push/subscriptions/remove"); + assert.deepEqual(JSON.parse(calls[0].options.body), { + endpoint: "https://fcm.googleapis.com/fcm/send/abc", + }); + await assert.rejects(unsubscribeWebPush({}), /endpoint is required/); +}); diff --git a/crates/product/ironclaw_webui/frontend/src/lib/api.ts b/crates/product/ironclaw_webui/frontend/src/lib/api.ts index ffe44039cd5..7e753c6ee97 100644 --- a/crates/product/ironclaw_webui/frontend/src/lib/api.ts +++ b/crates/product/ironclaw_webui/frontend/src/lib/api.ts @@ -403,6 +403,39 @@ export function setNotificationChannels({ targetIds } = {}) { }); } +// --- Web push (browser notifications) --- + +export function getWebPushStatus() { + return apiFetch(`${V2_BASE}/web-push/status`); +} + +export function subscribeWebPush({ endpoint, keys, userAgent } = {}) { + if (!endpoint) { + return Promise.reject(new Error("endpoint is required")); + } + if (!keys || !keys.p256dh || !keys.auth) { + return Promise.reject(new Error("keys.p256dh and keys.auth are required")); + } + return apiFetch(`${V2_BASE}/web-push/subscriptions`, { + method: "POST", + body: JSON.stringify({ + endpoint, + keys: { p256dh: keys.p256dh, auth: keys.auth }, + user_agent: userAgent || undefined, + }), + }); +} + +export function unsubscribeWebPush({ endpoint } = {}) { + if (!endpoint) { + return Promise.reject(new Error("endpoint is required")); + } + return apiFetch(`${V2_BASE}/web-push/subscriptions/remove`, { + method: "POST", + body: JSON.stringify({ endpoint }), + }); +} + // --- Operator logs --- export function queryLogs({ diff --git a/crates/product/ironclaw_webui/frontend/src/lib/web-push.test.ts b/crates/product/ironclaw_webui/frontend/src/lib/web-push.test.ts new file mode 100644 index 00000000000..22cebdfa1ae --- /dev/null +++ b/crates/product/ironclaw_webui/frontend/src/lib/web-push.test.ts @@ -0,0 +1,189 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { afterEach, test, vi } from "vitest"; + +vi.mock("./api", () => ({ + subscribeWebPush: vi.fn(async () => ({ outcome: "enrolled" })), + unsubscribeWebPush: vi.fn(async () => ({ removed: true })), +})); + +import { subscribeWebPush, unsubscribeWebPush } from "./api"; +import { + enrollThisBrowser, + getWebPushBrowserState, + registerServiceWorker, + unenrollThisBrowser, + urlBase64ToUint8Array, +} from "./web-push"; + +const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); +const originalNotification = Object.getOwnPropertyDescriptor(globalThis, "Notification"); + +function setGlobal(name, value) { + Object.defineProperty(globalThis, name, { + value, + configurable: true, + writable: true, + }); +} + +function restoreGlobal(name, descriptor) { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + delete globalThis[name]; + } +} + +afterEach(() => { + restoreGlobal("navigator", originalNavigator); + restoreGlobal("window", originalWindow); + restoreGlobal("Notification", originalNotification); + vi.clearAllMocks(); +}); + +function browserEnvironment({ + permission = "default", + subscription = null, + subscribeResult = null, + registrationOverrides = {}, +} = {}) { + const subscribeCalls = []; + const registration = { + pushManager: { + getSubscription: async () => subscription, + subscribe: async (options) => { + subscribeCalls.push(options); + if (subscribeResult) return subscribeResult; + throw new Error("no subscribe result configured"); + }, + }, + ...registrationOverrides, + }; + const registerCalls = []; + setGlobal("navigator", { + userAgent: "TestBrowser/1.0", + serviceWorker: { + register: async (url) => { + registerCalls.push(url); + return registration; + }, + ready: Promise.resolve(registration), + }, + }); + setGlobal("window", { PushManager: function PushManager() {} }); + setGlobal("Notification", { + permission, + requestPermission: async () => { + const next = permission === "default" ? "granted" : permission; + globalThis.Notification.permission = next; + return next; + }, + }); + return { registration, registerCalls, subscribeCalls }; +} + +function fakeSubscription(endpoint) { + return { + endpoint, + toJSON: () => ({ endpoint, keys: { p256dh: "pk", auth: "as" } }), + unsubscribe: vi.fn(async () => true), + }; +} + +test("urlBase64ToUint8Array decodes an unpadded base64url key", () => { + // "AQAB" base64url → bytes [1, 0, 1]. + assert.deepEqual(Array.from(urlBase64ToUint8Array("AQAB")), [1, 0, 1]); + // URL-safe alphabet round-trip: "_-8" → [0xff, 0xef]. + assert.deepEqual(Array.from(urlBase64ToUint8Array("_-8")), [0xff, 0xef]); + assert.throws(() => urlBase64ToUint8Array(""), /base64url key is required/); +}); + +test("registerServiceWorker registers /sw.js and swallows failures", async () => { + const { registerCalls } = browserEnvironment(); + await registerServiceWorker(); + assert.deepEqual(registerCalls, ["/sw.js"]); + + setGlobal("navigator", { + serviceWorker: { + register: async () => { + throw new Error("registration exploded"); + }, + }, + }); + const result = await registerServiceWorker(); + assert.equal(result, null, "a failed registration resolves null, never throws"); + + setGlobal("navigator", {}); + assert.equal(await registerServiceWorker(), null, "no serviceWorker support is a no-op"); +}); + +test("getWebPushBrowserState distinguishes unsupported, denied, not-enrolled, and enrolled", async () => { + setGlobal("navigator", {}); + setGlobal("window", {}); + assert.deepEqual(await getWebPushBrowserState(), { state: "unsupported" }); + + browserEnvironment({ permission: "denied" }); + assert.deepEqual(await getWebPushBrowserState(), { state: "permission-denied" }); + + browserEnvironment({ permission: "granted", subscription: null }); + assert.deepEqual(await getWebPushBrowserState(), { state: "not-enrolled" }); + + browserEnvironment({ + permission: "granted", + subscription: fakeSubscription("https://fcm.googleapis.com/fcm/send/abc"), + }); + assert.deepEqual(await getWebPushBrowserState(), { + state: "enrolled", + endpoint: "https://fcm.googleapis.com/fcm/send/abc", + }); +}); + +test("enrollThisBrowser subscribes with the VAPID key and registers with the backend", async () => { + const { subscribeCalls } = browserEnvironment({ + permission: "default", + subscription: null, + subscribeResult: fakeSubscription("https://fcm.googleapis.com/fcm/send/new"), + }); + + const state = await enrollThisBrowser({ vapidPublicKey: "AQAB" }); + + assert.deepEqual(state, { + state: "enrolled", + endpoint: "https://fcm.googleapis.com/fcm/send/new", + }); + assert.equal(subscribeCalls.length, 1); + assert.equal(subscribeCalls[0].userVisibleOnly, true); + assert.deepEqual(Array.from(subscribeCalls[0].applicationServerKey), [1, 0, 1]); + assert.equal(subscribeWebPush.mock.calls.length, 1); + assert.deepEqual(subscribeWebPush.mock.calls[0][0], { + endpoint: "https://fcm.googleapis.com/fcm/send/new", + keys: { p256dh: "pk", auth: "as" }, + userAgent: "TestBrowser/1.0", + }); +}); + +test("enrollThisBrowser reports a denied permission without subscribing", async () => { + const { subscribeCalls } = browserEnvironment({ permission: "denied" }); + const state = await enrollThisBrowser({ vapidPublicKey: "AQAB" }); + assert.deepEqual(state, { state: "permission-denied" }); + assert.equal(subscribeCalls.length, 0); + assert.equal(subscribeWebPush.mock.calls.length, 0); + await assert.rejects(enrollThisBrowser({}), /vapidPublicKey is required/); +}); + +test("unenrollThisBrowser unsubscribes locally even when the backend removal fails", async () => { + const subscription = fakeSubscription("https://fcm.googleapis.com/fcm/send/old"); + browserEnvironment({ permission: "granted", subscription }); + unsubscribeWebPush.mockRejectedValueOnce(new Error("backend offline")); + + const state = await unenrollThisBrowser(); + + assert.deepEqual(state, { state: "not-enrolled" }); + assert.equal(subscription.unsubscribe.mock.calls.length, 1); + assert.equal(unsubscribeWebPush.mock.calls.length, 1); + assert.deepEqual(unsubscribeWebPush.mock.calls[0][0], { + endpoint: "https://fcm.googleapis.com/fcm/send/old", + }); +}); diff --git a/crates/product/ironclaw_webui/frontend/src/lib/web-push.ts b/crates/product/ironclaw_webui/frontend/src/lib/web-push.ts new file mode 100644 index 00000000000..47b0551d5f9 --- /dev/null +++ b/crates/product/ironclaw_webui/frontend/src/lib/web-push.ts @@ -0,0 +1,143 @@ +// @ts-nocheck +// Browser-side Web Push enrollment for the web-push notification channel. +// +// The service worker (`public/sw.js`) displays pushes; this module owns +// registration and the per-browser subscription lifecycle against the +// `/api/webchat/v2/web-push/*` routes. All entry points are defensive: +// missing browser APIs degrade to an "unsupported" state and never throw +// into app boot or the settings panel. + +import { subscribeWebPush, unsubscribeWebPush } from "./api"; + +const SERVICE_WORKER_URL = "/sw.js"; + +/** Register the notification service worker. Safe to call on every boot; + * failures are logged and swallowed so app startup never depends on it. */ +export function registerServiceWorker() { + if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) { + return Promise.resolve(null); + } + return navigator.serviceWorker + .register(SERVICE_WORKER_URL) + .catch((error) => { + console.warn("IronClaw service worker registration failed", error); + return null; + }); +} + +function pushSupported() { + return ( + typeof navigator !== "undefined" && + "serviceWorker" in navigator && + typeof window !== "undefined" && + "PushManager" in window && + typeof Notification !== "undefined" + ); +} + +async function pushRegistration() { + // `ready` resolves once the boot-time registration activates; it never + // rejects for a registered worker, but guard a missing registration in + // browsers that lost it (e.g. cleared site data mid-session). + const registration = await navigator.serviceWorker.ready; + if (!registration || !registration.pushManager) return null; + return registration; +} + +/** + * The current browser's push state: + * { state: "unsupported" } + * { state: "permission-denied" } + * { state: "not-enrolled" } + * { state: "enrolled", endpoint } + */ +export async function getWebPushBrowserState() { + if (!pushSupported()) return { state: "unsupported" }; + if (Notification.permission === "denied") return { state: "permission-denied" }; + try { + const registration = await pushRegistration(); + if (!registration) return { state: "unsupported" }; + const subscription = await registration.pushManager.getSubscription(); + if (subscription && subscription.endpoint) { + return { state: "enrolled", endpoint: subscription.endpoint }; + } + return { state: "not-enrolled" }; + } catch (_) { + return { state: "not-enrolled" }; + } +} + +/** Decode an unpadded base64url VAPID public key into the byte array + * `pushManager.subscribe` expects. */ +export function urlBase64ToUint8Array(base64String) { + if (typeof base64String !== "string" || !base64String) { + throw new Error("a base64url key is required"); + } + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(base64); + const output = new Uint8Array(raw.length); + for (let index = 0; index < raw.length; index += 1) { + output[index] = raw.charCodeAt(index); + } + return output; +} + +function subscriptionKeys(subscription) { + const json = subscription.toJSON ? subscription.toJSON() : {}; + const keys = json.keys || {}; + if (!keys.p256dh || !keys.auth) { + throw new Error("push subscription did not expose p256dh/auth keys"); + } + return { p256dh: keys.p256dh, auth: keys.auth }; +} + +/** Ask for permission, subscribe this browser, and register the + * subscription with the backend. Returns the resulting browser state. */ +export async function enrollThisBrowser({ vapidPublicKey } = {}) { + if (!vapidPublicKey) { + throw new Error("vapidPublicKey is required"); + } + if (!pushSupported()) return { state: "unsupported" }; + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + return Notification.permission === "denied" + ? { state: "permission-denied" } + : { state: "not-enrolled" }; + } + const registration = await pushRegistration(); + if (!registration) return { state: "unsupported" }; + let subscription = await registration.pushManager.getSubscription(); + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + }); + } + await subscribeWebPush({ + endpoint: subscription.endpoint, + keys: subscriptionKeys(subscription), + userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined, + }); + return { state: "enrolled", endpoint: subscription.endpoint }; +} + +/** Unsubscribe this browser locally and remove it from the backend. + * Returns the resulting browser state. */ +export async function unenrollThisBrowser() { + if (!pushSupported()) return { state: "unsupported" }; + const registration = await pushRegistration(); + if (!registration) return { state: "unsupported" }; + const subscription = await registration.pushManager.getSubscription(); + if (!subscription) return { state: "not-enrolled" }; + const endpoint = subscription.endpoint; + await subscription.unsubscribe(); + // Backend removal is best-effort: the dead subscription is also pruned + // server-side on the next 404/410 push response. + try { + await unsubscribeWebPush({ endpoint }); + } catch (error) { + console.warn("web push backend unsubscribe failed", error); + } + return { state: "not-enrolled" }; +} diff --git a/crates/product/ironclaw_webui/frontend/src/main.tsx b/crates/product/ironclaw_webui/frontend/src/main.tsx index 3a272ce1be1..82f2f8cf223 100644 --- a/crates/product/ironclaw_webui/frontend/src/main.tsx +++ b/crates/product/ironclaw_webui/frontend/src/main.tsx @@ -4,10 +4,15 @@ import { createRoot } from "react-dom/client"; import { App } from "./app/app"; import { queryClient } from "./lib/query-client"; import { I18nProvider } from "./lib/i18n"; +import { registerServiceWorker } from "./lib/web-push"; // Only the English fallback is bundled eagerly; every other locale is // lazy-loaded on demand by I18nProvider (see lib/i18n.tsx `loaders`). import "./i18n/en"; +// Boot-time side effect: register the notification service worker (safe +// no-op in browsers without support; never blocks or fails rendering). +registerServiceWorker(); + createRoot(document.getElementById("v2-root")).render(( diff --git a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts index 4b775757654..4165eb774f4 100644 --- a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts +++ b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts @@ -13,7 +13,24 @@ const COPY = { "Choose which connected channels receive approval prompts, auth prompts, and run-failure notices.", "automations.notificationChannels.empty": "No connected channels yet.", "automations.notificationChannels.webOnlyHelper": - "Notifications stay in the web app", + "No notification channel is selected — approval prompts, auth prompts, and failure notices won't be delivered anywhere.", + "automations.notificationChannels.webPush.deviceHeading": "This browser", + "automations.notificationChannels.webPush.checking": + "Checking this browser's notification support…", + "automations.notificationChannels.webPush.unsupported": + "This browser doesn't support push notifications.", + "automations.notificationChannels.webPush.permissionDenied": + "Notifications are blocked for this site. Allow them in your browser's site settings, then try again.", + "automations.notificationChannels.webPush.notEnrolled": + "This browser isn't receiving notifications yet.", + "automations.notificationChannels.webPush.enrolled": + "This browser receives notifications.", + "automations.notificationChannels.webPush.enroll": + "Enable notifications in this browser", + "automations.notificationChannels.webPush.unenroll": "Disable in this browser", + "automations.notificationChannels.webPush.deviceCount": "Enrolled browsers: {count}", + "automations.notificationChannels.webPush.actionFailed": + "Couldn't update this browser's notification enrollment. Please try again.", "automations.notificationChannels.save": "Save", "automations.notificationChannels.saved": "Saved", "automations.notificationChannels.saveFailed": @@ -43,7 +60,7 @@ function sourceForTest() { } lines.push(line.replace(/^export function /, "function ")); } - return `${lines.join("\n")}\nglobalThis.__testExports = { NotificationChannelsPanel };`; + return `${lines.join("\n")}\nglobalThis.__testExports = { NotificationChannelsPanel, WebPushDeviceBlock };`; } function html(strings, ...values) { @@ -106,8 +123,11 @@ function nativeProps(root, tagName) { return props; } -function t(key) { - return COPY[key] || key; +function t(key, params = {}) { + const text = COPY[key] || key; + return text.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name) => + name in params ? String(params[name]) : match, + ); } function channel(targetId, { displayName, description, status = "available" } = {}) { @@ -151,7 +171,7 @@ function mergeRows(targets, channels) { return { rows, selected }; } -function createHarness({ saveNotificationChannels = async () => {}, isLoading = false, isSaving = false, error = null, saveError = null } = {}) { +function createHarness({ saveNotificationChannels = async () => {}, isLoading = false, isSaving = false, error = null, saveError = null, webPushDevice = null } = {}) { const hookValues = []; const effectDeps = []; let hookCursor = 0; @@ -224,6 +244,18 @@ function createHarness({ saveNotificationChannels = async () => {}, isLoading = cn: (...parts) => parts.filter(Boolean).join(" "), html, useT: () => t, + useWebPushDevice: () => + webPushDevice || { + browser: { state: "not-enrolled" }, + subscriptionCount: 0, + vapidPublicKey: "test-vapid-key", + isStatusLoading: false, + statusError: null, + isBusy: false, + actionError: null, + enroll: async () => ({ state: "enrolled" }), + unenroll: async () => ({ state: "not-enrolled" }), + }, }; vm.runInNewContext(sourceForTest(), context); @@ -312,7 +344,7 @@ test("NotificationChannelsPanel locks editing after a failed read so Save cannot "the user must be told why the panel is disabled, not left with an inert form", ); assert.ok( - !scalars.includes("Notifications stay in the web app"), + !scalars.includes("No notification channel is selected — approval prompts, auth prompts, and failure notices won't be delivered anywhere."), "the empty-selection helper is a claim about the stored set — a failed read must not assert it", ); }); @@ -397,7 +429,7 @@ test("NotificationChannelsPanel shows the web-app-only helper text once every ch channels: [channel("slack-alpha")], }); assert.ok( - !collectScalars(rendered).includes("Notifications stay in the web app"), + !collectScalars(rendered).includes("No notification channel is selected — approval prompts, auth prompts, and failure notices won't be delivered anywhere."), "helper text must not show while a channel is still selected", ); @@ -409,7 +441,7 @@ test("NotificationChannelsPanel shows the web-app-only helper text once every ch channels: [channel("slack-alpha")], }); assert.ok( - collectScalars(rendered).includes("Notifications stay in the web app"), + collectScalars(rendered).includes("No notification channel is selected — approval prompts, auth prompts, and failure notices won't be delivered anywhere."), "unchecking every channel must show the empty-selection helper text", ); }); @@ -591,3 +623,182 @@ test("NotificationChannelsPanel does not hardcode a catalog row's badge to Ready "the Ready badge must not render for a row the response marked unavailable", ); }); + +// ── Web-push row: the device block under the "Web app" channel ───────────── + +function webPushTarget() { + return { + target: { + target_id: "web-push", + display_name: "Web app", + description: "Browser push notifications to your enrolled devices", + channel: "web-push", + }, + capabilities: { final_replies: true, gate_prompts: true, auth_prompts: true }, + }; +} + +function fakeDevice(overrides = {}) { + return { + browser: { state: "not-enrolled" }, + subscriptionCount: 0, + vapidPublicKey: "test-vapid-key", + isStatusLoading: false, + statusError: null, + isBusy: false, + actionError: null, + enroll: async () => ({ state: "enrolled" }), + unenroll: async () => ({ state: "not-enrolled" }), + ...overrides, + }; +} + +// The vm harness never invokes child components — they appear in the panel +// tree as (component, props) nodes. So the panel-level tests assert the +// block is MOUNTED with the right props (and only under the web-push row), +// and the block's own rendering is asserted by calling it directly. + +test("NotificationChannelsPanel mounts the device block under the web-push row only", () => { + const harness = createHarness(); + const withWebPush = harness.render({ + targets: [target("slack-alpha"), webPushTarget()], + channels: [], + }); + const blocks = componentProps(withWebPush, harness.exports.WebPushDeviceBlock); + assert.equal(blocks.length, 1, "exactly one device block for the web-push row"); + assert.ok(blocks[0].device, "the block receives the device state"); + + const withoutWebPush = harness.render({ + targets: [target("slack-alpha"), target("slack-beta")], + channels: [], + }); + assert.equal( + componentProps(withoutWebPush, harness.exports.WebPushDeviceBlock).length, + 0, + "no device block without a web-push row", + ); +}); + +test("NotificationChannelsPanel toggles the web-push target into the full-replace save set like any channel", () => { + const saved = []; + const harness = createHarness({ + saveNotificationChannels: async (targetIds) => { + saved.push(targetIds); + }, + }); + let rendered = harness.render({ + targets: [webPushTarget()], + channels: [], + }); + const [checkbox] = nativeProps(rendered, "input"); + assert.equal(checkbox.checked, false); + checkbox.onChange({ target: { checked: true } }); + + rendered = harness.render({ targets: [webPushTarget()], channels: [] }); + const [saveButton] = componentProps(rendered, harness.Button); + assert.equal(saveButton.disabled, false, "a staged web-push toggle enables Save"); + saveButton.onClick(); + assert.equal(saved.length, 1); + assert.deepEqual( + [...saved[0]], + ["web-push"], + "Save posts the web-push id in target_ids", + ); +}); + +test("WebPushDeviceBlock distinguishes unsupported, denied, not-enrolled, and enrolled browsers", () => { + const harness = createHarness(); + const block = harness.exports.WebPushDeviceBlock; + const cases = [ + [ + { state: "unsupported" }, + "This browser doesn't support push notifications.", + ], + [ + { state: "permission-denied" }, + "Notifications are blocked for this site. Allow them in your browser's site settings, then try again.", + ], + [ + { state: "not-enrolled" }, + "This browser isn't receiving notifications yet.", + ], + [ + { state: "enrolled", endpoint: "https://fcm.googleapis.com/send/x" }, + "This browser receives notifications.", + ], + ]; + for (const [browser, expectedCopy] of cases) { + const rendered = block({ + device: fakeDevice({ browser, subscriptionCount: 2 }), + t, + }); + const scalars = collectScalars(rendered); + assert.ok(scalars.includes("This browser"), "device heading renders"); + assert.ok( + scalars.includes(expectedCopy), + `state ${browser.state} must render its copy`, + ); + assert.ok( + scalars.includes("Enrolled browsers: 2"), + "the enrolled-device count is interpolated", + ); + } +}); + +test("WebPushDeviceBlock enroll and unenroll buttons call the device hook", () => { + const harness = createHarness(); + const block = harness.exports.WebPushDeviceBlock; + + const enrollCalls = []; + const notEnrolled = block({ + device: fakeDevice({ + enroll: async () => { + enrollCalls.push("enroll"); + return { state: "enrolled" }; + }, + }), + t, + }); + const [enrollButton] = componentProps(notEnrolled, harness.Button); + assert.ok(enrollButton, "the enroll button renders for a not-enrolled browser"); + assert.equal(enrollButton.disabled, false); + enrollButton.onClick(); + assert.deepEqual(enrollCalls, ["enroll"]); + + const unenrollCalls = []; + const enrolled = block({ + device: fakeDevice({ + browser: { state: "enrolled", endpoint: "https://fcm.googleapis.com/send/x" }, + unenroll: async () => { + unenrollCalls.push("unenroll"); + return { state: "not-enrolled" }; + }, + }), + t, + }); + const [unenrollButton] = componentProps(enrolled, harness.Button); + assert.ok(unenrollButton, "the unenroll button renders for an enrolled browser"); + unenrollButton.onClick(); + assert.deepEqual(unenrollCalls, ["unenroll"]); + + const busy = block({ + device: fakeDevice({ isBusy: true }), + t, + }); + const [busyButton] = componentProps(busy, harness.Button); + assert.equal(busyButton.disabled, true, "actions lock while a request is in flight"); +}); + +test("WebPushDeviceBlock surfaces an action failure", () => { + const harness = createHarness(); + const block = harness.exports.WebPushDeviceBlock; + const rendered = block({ + device: fakeDevice({ actionError: new Error("nope") }), + t, + }); + assert.ok( + collectScalars(rendered).includes( + "Couldn't update this browser's notification enrollment. Please try again.", + ), + ); +}); diff --git a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx index 198f9289f28..a6fac4c1945 100644 --- a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx +++ b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx @@ -4,6 +4,13 @@ import { Badge, Panel } from "../../../design-system/primitives"; import React from "react"; import { useT } from "../../../lib/i18n"; import { cn } from "../../../utils/cn"; +import { useWebPushDevice } from "../hooks/useWebPushDevice"; + +// The web-push catalog row's channel label (mirrors +// `ironclaw_web_push::WEB_PUSH_CHANNEL_NAME`). The row itself toggles like +// any other channel; matching on the channel only adds the per-browser +// device block underneath it. +const WEB_PUSH_CHANNEL = "web-push"; /** * Resolve a Badge tone for a notification-channel row. @@ -16,8 +23,82 @@ function rowTone(status) { return "success"; } +/** + * The per-browser device state rendered under the "Web app" channel row. + * Account-level routing (the checkbox) and device enrollment (this block) + * are deliberately separate dimensions: selecting the channel routes + * notifications to the account's enrolled browsers, and this block manages + * whether THIS browser is one of them. + */ +function WebPushDeviceBlock({ device, t }) { + const state = device.browser?.state || "checking"; + let stateCopy; + if (state === "unsupported") { + stateCopy = t("automations.notificationChannels.webPush.unsupported"); + } else if (state === "permission-denied") { + stateCopy = t("automations.notificationChannels.webPush.permissionDenied"); + } else if (state === "enrolled") { + stateCopy = t("automations.notificationChannels.webPush.enrolled"); + } else if (state === "not-enrolled") { + stateCopy = t("automations.notificationChannels.webPush.notEnrolled"); + } else { + stateCopy = t("automations.notificationChannels.webPush.checking"); + } + const canEnroll = state === "not-enrolled" && !device.isBusy && device.vapidPublicKey; + const canUnenroll = state === "enrolled" && !device.isBusy; + return ( +
+
+ {t("automations.notificationChannels.webPush.deviceHeading")} +
+
{stateCopy}
+
+ {t("automations.notificationChannels.webPush.deviceCount", { + count: device.subscriptionCount, + })} +
+ {(state === "not-enrolled" || state === "enrolled") && + ( +
+ {state === "not-enrolled" && + ( + + )} + {state === "enrolled" && + ( + + )} +
+ )} + {device.actionError && + ( +
+ {t("automations.notificationChannels.webPush.actionFailed")} +
+ )} +
+ ); +} + export function NotificationChannelsPanel({ channelsState }) { const t = useT(); + const webPushDevice = useWebPushDevice(); const rows = channelsState.rows; // `selectedIds` is the server truth (the caller's stored notification- // channel set, spec §7); `draftIds` is the staged, locally-toggled copy @@ -156,9 +237,9 @@ export function NotificationChannelsPanel({ channelsState }) { {rows.map((row) => { const isSelected = draftIds.has(row.target_id); const isUnavailable = row.status === "unavailable"; - return ( + const isWebPushRow = row.channel === WEB_PUSH_CHANNEL; + const rowLabel = ( ); + return ( +
+ {rowLabel} + {isWebPushRow && + ()} +
+ ); })} {!hasLoadError && rows.length === 0 && diff --git a/crates/product/ironclaw_webui/frontend/src/pages/automations/hooks/useWebPushDevice.ts b/crates/product/ironclaw_webui/frontend/src/pages/automations/hooks/useWebPushDevice.ts new file mode 100644 index 00000000000..a5336ee7e15 --- /dev/null +++ b/crates/product/ironclaw_webui/frontend/src/pages/automations/hooks/useWebPushDevice.ts @@ -0,0 +1,74 @@ +// @ts-nocheck +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import React from "react"; +import { getWebPushStatus } from "../../../lib/api"; +import { + enrollThisBrowser, + getWebPushBrowserState, + unenrollThisBrowser, +} from "../../../lib/web-push"; + +const WEB_PUSH_STATUS_QUERY_KEY = ["web-push", "status"]; + +/** + * Per-browser web-push device state for the notification-channels panel's + * "Web app" row: the account-level enrollment summary (backend status view) + * plus this browser's own push state, with enroll/unenroll actions. + * + * The account-level toggle (whether the web-push target is a notification + * channel) stays in the ordinary draft/save set — this hook only manages the + * device dimension underneath it. + */ +export function useWebPushDevice() { + const queryClient = useQueryClient(); + const statusQuery = useQuery({ + queryKey: WEB_PUSH_STATUS_QUERY_KEY, + queryFn: getWebPushStatus, + }); + + const [browser, setBrowser] = React.useState({ state: "checking" }); + React.useEffect(() => { + let cancelled = false; + getWebPushBrowserState().then( + (state) => { + if (!cancelled) setBrowser(state); + }, + () => { + if (!cancelled) setBrowser({ state: "unsupported" }); + }, + ); + return () => { + cancelled = true; + }; + }, []); + + const refreshAfterAction = async (nextState) => { + setBrowser(nextState); + await queryClient.invalidateQueries({ queryKey: WEB_PUSH_STATUS_QUERY_KEY }); + return nextState; + }; + + const enrollMutation = useMutation({ + mutationFn: () => { + const vapidPublicKey = statusQuery.data?.vapid_public_key; + return enrollThisBrowser({ vapidPublicKey }); + }, + onSuccess: (nextState) => refreshAfterAction(nextState), + }); + const unenrollMutation = useMutation({ + mutationFn: () => unenrollThisBrowser(), + onSuccess: (nextState) => refreshAfterAction(nextState), + }); + + return { + browser, + subscriptionCount: statusQuery.data?.subscription_count ?? 0, + vapidPublicKey: statusQuery.data?.vapid_public_key || "", + isStatusLoading: statusQuery.isLoading, + statusError: statusQuery.error || null, + isBusy: enrollMutation.isPending || unenrollMutation.isPending, + actionError: enrollMutation.error || unenrollMutation.error || null, + enroll: () => enrollMutation.mutateAsync(), + unenroll: () => unenrollMutation.mutateAsync(), + }; +} diff --git a/crates/product/ironclaw_webui/src/webui_v2/descriptors.rs b/crates/product/ironclaw_webui/src/webui_v2/descriptors.rs index 17d04ae1a70..8f75dfa6c3b 100644 --- a/crates/product/ironclaw_webui/src/webui_v2/descriptors.rs +++ b/crates/product/ironclaw_webui/src/webui_v2/descriptors.rs @@ -54,6 +54,9 @@ pub const WEBUI_V2_ROUTE_LIST_OUTBOUND_DELIVERY_TARGETS: &str = "webui.v2.list_outbound_delivery_targets"; pub const WEBUI_V2_ROUTE_GET_NOTIFICATION_CHANNELS: &str = "webui.v2.get_notification_channels"; pub const WEBUI_V2_ROUTE_SET_NOTIFICATION_CHANNELS: &str = "webui.v2.set_notification_channels"; +pub const WEBUI_V2_ROUTE_WEB_PUSH_STATUS: &str = "webui.v2.web_push_status"; +pub const WEBUI_V2_ROUTE_WEB_PUSH_SUBSCRIBE: &str = "webui.v2.web_push_subscribe"; +pub const WEBUI_V2_ROUTE_WEB_PUSH_UNSUBSCRIBE: &str = "webui.v2.web_push_unsubscribe"; pub const WEBUI_V2_ROUTE_LIST_EXTENSIONS: &str = "webui.v2.list_extensions"; pub const WEBUI_V2_ROUTE_LIST_EXTENSION_REGISTRY: &str = "webui.v2.list_extension_registry"; pub const WEBUI_V2_ROUTE_INSTALL_EXTENSION: &str = "webui.v2.install_extension"; @@ -173,6 +176,10 @@ pub const WEBUI_V2_PATTERN_TRACE_ACCOUNT_LOGIN_LINK: &str = pub const WEBUI_V2_PATTERN_OUTBOUND_DELIVERY_TARGETS: &str = "/api/webchat/v2/outbound/targets"; pub const WEBUI_V2_PATTERN_NOTIFICATION_CHANNELS: &str = "/api/webchat/v2/outbound/notification-channels"; +pub const WEBUI_V2_PATTERN_WEB_PUSH_STATUS: &str = "/api/webchat/v2/web-push/status"; +pub const WEBUI_V2_PATTERN_WEB_PUSH_SUBSCRIPTIONS: &str = "/api/webchat/v2/web-push/subscriptions"; +pub const WEBUI_V2_PATTERN_WEB_PUSH_SUBSCRIPTIONS_REMOVE: &str = + "/api/webchat/v2/web-push/subscriptions/remove"; pub const WEBUI_V2_PATTERN_ADMIN_USERS: &str = "/api/webchat/v2/admin/users"; pub const WEBUI_V2_PATTERN_ADMIN_USER: &str = "/api/webchat/v2/admin/users/{user_id}"; pub const WEBUI_V2_PATTERN_ADMIN_USER_STATUS: &str = "/api/webchat/v2/admin/users/{user_id}/status"; @@ -318,6 +325,9 @@ pub fn webui_v2_routes_with_artifact_flags( list_outbound_delivery_targets_descriptor(), get_notification_channels_descriptor(), set_notification_channels_descriptor(), + web_push_status_descriptor(), + web_push_subscribe_descriptor(), + web_push_unsubscribe_descriptor(), list_extensions_descriptor(), list_extension_registry_descriptor(), install_extension_descriptor(), @@ -1208,6 +1218,50 @@ fn set_notification_channels_descriptor() -> IngressRouteDescriptor { ) } +fn web_push_status_descriptor() -> IngressRouteDescriptor { + descriptor( + WEBUI_V2_ROUTE_WEB_PUSH_STATUS, + NetworkMethod::Get, + WEBUI_V2_PATTERN_WEB_PUSH_STATUS, + read_policy( + read_rate_limit(), + AuditTraceClass::UserAction, + AllowedEffectPath::ProductSurface, + StreamingMode::None, + ), + ) +} + +fn web_push_subscribe_descriptor() -> IngressRouteDescriptor { + descriptor( + WEBUI_V2_ROUTE_WEB_PUSH_SUBSCRIBE, + NetworkMethod::Post, + WEBUI_V2_PATTERN_WEB_PUSH_SUBSCRIPTIONS, + mutation_policy( + // A push subscription is a ~1 KiB endpoint URL plus two short + // base64url keys; 8 KiB leaves defensive headroom. + body_limit_kib(8), + mutation_rate_limit(), + AuditTraceClass::UserAction, + AllowedEffectPath::ProductSurface, + ), + ) +} + +fn web_push_unsubscribe_descriptor() -> IngressRouteDescriptor { + descriptor( + WEBUI_V2_ROUTE_WEB_PUSH_UNSUBSCRIBE, + NetworkMethod::Post, + WEBUI_V2_PATTERN_WEB_PUSH_SUBSCRIPTIONS_REMOVE, + mutation_policy( + body_limit_kib(8), + mutation_rate_limit(), + AuditTraceClass::UserAction, + AllowedEffectPath::ProductSurface, + ), + ) +} + fn list_extensions_descriptor() -> IngressRouteDescriptor { descriptor( WEBUI_V2_ROUTE_LIST_EXTENSIONS, diff --git a/crates/product/ironclaw_webui/src/webui_v2/handlers.rs b/crates/product/ironclaw_webui/src/webui_v2/handlers.rs index 75586e0e5b4..6c6998ded2a 100644 --- a/crates/product/ironclaw_webui/src/webui_v2/handlers.rs +++ b/crates/product/ironclaw_webui/src/webui_v2/handlers.rs @@ -115,6 +115,13 @@ use ironclaw_product_contracts::product_wire::{ SettingsToolPermissionState, }; use ironclaw_product_contracts::views::{RebornViewDescriptor, RebornViewPage, RebornViewQuery}; +use ironclaw_product_contracts::product_wire::{ + RebornWebPushStatusResponse, RebornWebPushSubscribeRequest, RebornWebPushSubscribeResponse, + RebornWebPushUnsubscribeRequest, RebornWebPushUnsubscribeResponse, +}; +use ironclaw_product_contracts::web_push::{ + WEB_PUSH_STATUS_VIEW, WEB_PUSH_SUBSCRIBE_COMMAND, WEB_PUSH_UNSUBSCRIBE_COMMAND, +}; use ironclaw_product_contracts::workspace_views::{ FsMount, ProjectFsFile, RebornAddMemberRequest, RebornCreateProjectRequest, RebornDeleteProjectRequest, RebornFsListRequest, RebornFsListResponse, RebornFsMountsRequest, @@ -2258,6 +2265,56 @@ pub async fn set_notification_channels( Ok(Json(response)) } +/// `GET /api/webchat/v2/web-push/status` +/// +/// The deployment's VAPID public key (`applicationServerKey`) plus the +/// caller's enrolled browsers — redacted to push-service hosts; endpoint +/// capability URLs never leave the backend. +pub async fn web_push_status( + State(state): State, + Extension(caller): Extension, +) -> Result, WebUiV2HttpError> { + let response = query_product_view( + state.services(), + caller, + WEB_PUSH_STATUS_VIEW.descriptor(), + serde_json::json!({}), + None, + ) + .await?; + Ok(Json(response)) +} + +/// `POST /api/webchat/v2/web-push/subscriptions` +/// +/// Enroll (or refresh) the caller's current browser for web push. Body: +/// [`RebornWebPushSubscribeRequest`]; the endpoint is validated against the +/// supported push-service allowlist before persistence. +pub async fn web_push_subscribe( + State(state): State, + Extension(caller): Extension, + Json(body): Json, +) -> Result, WebUiV2HttpError> { + let response = + invoke_product_command(state.services(), caller, WEB_PUSH_SUBSCRIBE_COMMAND, body).await?; + Ok(Json(response)) +} + +/// `POST /api/webchat/v2/web-push/subscriptions/remove` +/// +/// Remove one of the caller's browser enrollments by endpoint. POST (not +/// DELETE) because the endpoint is a long capability URL carried in the body. +pub async fn web_push_unsubscribe( + State(state): State, + Extension(caller): Extension, + Json(body): Json, +) -> Result, WebUiV2HttpError> { + let response = + invoke_product_command(state.services(), caller, WEB_PUSH_UNSUBSCRIBE_COMMAND, body) + .await?; + Ok(Json(response)) +} + /// `GET /api/webchat/v2/extensions` pub async fn list_extensions( State(state): State, diff --git a/crates/product/ironclaw_webui/src/webui_v2/mod.rs b/crates/product/ironclaw_webui/src/webui_v2/mod.rs index 45b7cc157c2..2c58d911f9a 100644 --- a/crates/product/ironclaw_webui/src/webui_v2/mod.rs +++ b/crates/product/ironclaw_webui/src/webui_v2/mod.rs @@ -100,8 +100,10 @@ pub use descriptors::{ WEBUI_V2_ROUTE_TRACE_CREDITS, WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, WEBUI_V2_ROUTE_UPDATE_PROJECT, WEBUI_V2_ROUTE_UPDATE_PROJECT_MEMBER, WEBUI_V2_ROUTE_UPDATE_SKILL, WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, - is_webui_v2_operator_webui_config_route_id, webui_v2_routes, - webui_v2_routes_with_artifact_flags, webui_v2_routes_with_regression_artifact_export, + WEBUI_V2_ROUTE_WEB_PUSH_STATUS, WEBUI_V2_ROUTE_WEB_PUSH_SUBSCRIBE, + WEBUI_V2_ROUTE_WEB_PUSH_UNSUBSCRIBE, is_webui_v2_operator_webui_config_route_id, + webui_v2_routes, webui_v2_routes_with_artifact_flags, + webui_v2_routes_with_regression_artifact_export, }; pub use error::{WebUiV2HttpError, WebUiV2HttpErrorBody}; pub use handlers::{ diff --git a/crates/product/ironclaw_webui/src/webui_v2/router.rs b/crates/product/ironclaw_webui/src/webui_v2/router.rs index ac2905b15e5..7e5094c69a1 100644 --- a/crates/product/ironclaw_webui/src/webui_v2/router.rs +++ b/crates/product/ironclaw_webui/src/webui_v2/router.rs @@ -57,7 +57,8 @@ use crate::webui_v2::descriptors::{ WEBUI_V2_PATTERN_STREAM_EVENTS, WEBUI_V2_PATTERN_STREAM_EVENTS_WS, WEBUI_V2_PATTERN_TEST_LLM_CONNECTION, WEBUI_V2_PATTERN_TRACE_ACCOUNT_LOGIN_LINK, WEBUI_V2_PATTERN_TRACE_ACCOUNT_TRACES, WEBUI_V2_PATTERN_TRACE_CREDITS, - WEBUI_V2_PATTERN_TRACE_HOLD_AUTHORIZE, + WEBUI_V2_PATTERN_TRACE_HOLD_AUTHORIZE, WEBUI_V2_PATTERN_WEB_PUSH_STATUS, + WEBUI_V2_PATTERN_WEB_PUSH_SUBSCRIPTIONS, WEBUI_V2_PATTERN_WEB_PUSH_SUBSCRIPTIONS_REMOVE, }; use crate::webui_v2::handlers; use crate::webui_v2::sse_capacity::SseCapacity; @@ -350,6 +351,18 @@ pub fn webui_v2_router_with_options(state: WebUiV2State, options: WebUiV2RouteOp WEBUI_V2_PATTERN_NOTIFICATION_CHANNELS, get(handlers::get_notification_channels).post(handlers::set_notification_channels), ) + .route( + WEBUI_V2_PATTERN_WEB_PUSH_STATUS, + get(handlers::web_push_status), + ) + .route( + WEBUI_V2_PATTERN_WEB_PUSH_SUBSCRIPTIONS, + post(handlers::web_push_subscribe), + ) + .route( + WEBUI_V2_PATTERN_WEB_PUSH_SUBSCRIPTIONS_REMOVE, + post(handlers::web_push_unsubscribe), + ) .route( WEBUI_V2_PATTERN_LIST_EXTENSIONS, get(handlers::list_extensions), diff --git a/crates/product/ironclaw_webui/tests/webui_v2_descriptors_contract.rs b/crates/product/ironclaw_webui/tests/webui_v2_descriptors_contract.rs index 631472c540a..a117506c872 100644 --- a/crates/product/ironclaw_webui/tests/webui_v2_descriptors_contract.rs +++ b/crates/product/ironclaw_webui/tests/webui_v2_descriptors_contract.rs @@ -67,7 +67,9 @@ use ironclaw_webui::webui_v2::{ WEBUI_V2_ROUTE_TRACE_ACCOUNT_LOGIN_LINK, WEBUI_V2_ROUTE_TRACE_ACCOUNT_TRACES, WEBUI_V2_ROUTE_TRACE_CREDITS, WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, WEBUI_V2_ROUTE_UPDATE_PROJECT, WEBUI_V2_ROUTE_UPDATE_PROJECT_MEMBER, - WEBUI_V2_ROUTE_UPDATE_SKILL, WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, webui_v2_routes, + WEBUI_V2_ROUTE_UPDATE_SKILL, WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, + WEBUI_V2_ROUTE_WEB_PUSH_STATUS, WEBUI_V2_ROUTE_WEB_PUSH_SUBSCRIBE, + WEBUI_V2_ROUTE_WEB_PUSH_UNSUBSCRIBE, webui_v2_routes, }; /// Expected policy surface for one route. Everything host composition @@ -643,6 +645,57 @@ fn expected_table() -> Vec { audit: AuditTraceClass::UserAction, effect_path: AllowedEffectPath::ProductSurface, }, + Expected { + route_id: WEBUI_V2_ROUTE_WEB_PUSH_STATUS, + method: NetworkMethod::Get, + pattern: "/api/webchat/v2/web-push/status", + listener_class: ListenerClass::LocalGateway, + auth_schemes: &[IngressAuthScheme::BearerToken], + scope_source: IngressScopeSource::AuthenticatedCaller, + body_limit: BodyLimitPolicy::NoBody, + rate_limit_max: 120, + rate_limit_window_seconds: 60, + rate_limit_scope: RateLimitScope::PerCaller, + cors: CorsPolicy::SameOriginOnly, + websocket_origin: WebSocketOriginPolicy::NotApplicable, + streaming: StreamingMode::None, + audit: AuditTraceClass::UserAction, + effect_path: AllowedEffectPath::ProductSurface, + }, + Expected { + route_id: WEBUI_V2_ROUTE_WEB_PUSH_SUBSCRIBE, + method: NetworkMethod::Post, + pattern: "/api/webchat/v2/web-push/subscriptions", + listener_class: ListenerClass::LocalGateway, + auth_schemes: &[IngressAuthScheme::BearerToken], + scope_source: IngressScopeSource::AuthenticatedCaller, + body_limit: body_limit_kib(8), + rate_limit_max: 60, + rate_limit_window_seconds: 60, + rate_limit_scope: RateLimitScope::PerCaller, + cors: CorsPolicy::SameOriginOnly, + websocket_origin: WebSocketOriginPolicy::NotApplicable, + streaming: StreamingMode::None, + audit: AuditTraceClass::UserAction, + effect_path: AllowedEffectPath::ProductSurface, + }, + Expected { + route_id: WEBUI_V2_ROUTE_WEB_PUSH_UNSUBSCRIBE, + method: NetworkMethod::Post, + pattern: "/api/webchat/v2/web-push/subscriptions/remove", + listener_class: ListenerClass::LocalGateway, + auth_schemes: &[IngressAuthScheme::BearerToken], + scope_source: IngressScopeSource::AuthenticatedCaller, + body_limit: body_limit_kib(8), + rate_limit_max: 60, + rate_limit_window_seconds: 60, + rate_limit_scope: RateLimitScope::PerCaller, + cors: CorsPolicy::SameOriginOnly, + websocket_origin: WebSocketOriginPolicy::NotApplicable, + streaming: StreamingMode::None, + audit: AuditTraceClass::UserAction, + effect_path: AllowedEffectPath::ProductSurface, + }, Expected { route_id: WEBUI_V2_ROUTE_LIST_EXTENSIONS, method: NetworkMethod::Get, diff --git a/docs/internal/design/2026-08-08-web-push-notifications.md b/docs/internal/design/2026-08-08-web-push-notifications.md new file mode 100644 index 00000000000..3e33a3f2992 --- /dev/null +++ b/docs/internal/design/2026-08-08-web-push-notifications.md @@ -0,0 +1,173 @@ +# Web Push notifications for the web app (PWA) + +**Date:** 2026-08-08 · **Branch:** `webapp-push-notifications` · **Status:** In progress + +## Goal + +Make the web app a real, selectable notification route for automations — +parity with Slack/Telegram — plus PWA installability. Users can pick any +combination of Slack / Telegram / Web app (or none) in the automations page's +notification-channels panel, and the web app route delivers actual browser +push notifications (W3C Push API: RFC 8030 transport, RFC 8291 encryption, +RFC 8292 VAPID) through a service worker, including when the app is closed. + +## What exists today (verified against HEAD 30ae2d50f6) + +- The notification-channels panel (`crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx`) + edits one account-wide `CommunicationPreferenceRecord.notification_targets` + set (cap 8) via `GET/POST /api/webchat/v2/outbound/notification-channels`. + Rows come from the owner-scoped outbound target catalog + (`OutboundDeliveryTargetProvider` registry in `ironclaw_outbound`). +- "Web app" today is only a helper string shown when the set is empty + (`automations.notificationChannels.webOnlyHelper`); nothing is delivered. + The old `builtin:web_app` pseudo-target was deliberately deleted in #7157 + (it was an opt-out sentinel, not a destination) and + `tests/integration/outbound_target.rs` pins that id as non-addressable. +- Two-lane delivery contract: lane 1 = final reply always lands in the run + thread; lane 2 = model-called `builtin.outbound_deliver` to one catalog + target; lane 3 = host-emitted background-run notices (gate/auth/failure — + **not** Completed) fanned over the notification-channel set by + `TriggeredRunDeliveryDriver` → `DeliveryCoordinator` → `ChannelAdapter.deliver`. +- PWA manifest + maskable icons already ship (`frontend/public/assets/site.webmanifest`, + pinned by `root-paths.test.ts`); there is no service worker and no push code. + +## Decision + +Web push becomes a **bundled channel extension** (the repo's canonical shape: +"adding a channel means adding one capability surface of an extension … never +per-channel host code"), deployment-bound like Telegram, with the protocol +mechanics in a new domain crate. It is a genuinely external destination +(egress to browser push services), so it does not revive the retired +`builtin:web_app` in-app pseudo-target. + +### New crates + +1. **`crates/domains/ironclaw_web_push`** (family: domains, layer: substrates) + - `PushSubscriptionRecord` grammar + `WebPushSubscriptionStore`: typed + wrapper over `ScopedFilesystem` + `cas_update` (per database.md; one + JSON doc per tenant/user, endpoint-unique entries, bounded count). + - RFC 8291 `aes128gcm` payload encryption + RFC 8188 framing and the + compact ES256 JWS builder for VAPID — all via `aws-lc-rs` (already in + the lockfile via jsonwebtoken; zero new heavy deps): ECDH_P256, + HKDF_SHA256, AES_128_GCM, EcdsaKeyPair. Deterministic-key seams for the + RFC 8291 Appendix A test vector. + - VAPID key material generation (PKCS#8) producing the generic + `vapid_authorization` credential-material JSON (below). No secret + persistence here; composition seeds/stores it. + - Push endpoint allowlist (exact hosts, matching the manifest egress + declarations) + subscribe-time endpoint validation. FCM + (`fcm.googleapis.com`), Mozilla (`updates.push.services.mozilla.com`), + Apple (`web.push.apple.com`). Windows WNS uses per-tenant subdomains and + is out of scope v1 (documented, validated at enroll time with a clear + error). + - Pure push request builder: `(subscription, payload, ttl, urgency)` → + `{host, path, headers, ciphertext}`. No HTTP, no network/secrets deps — + the domains-family "no transport sends" rule holds. + - `OutboundDeliveryTargetProvider` impl over the store: one owner-scoped + entry per user ("Web app — browser notifications"), channel `web-push`, + capabilities `{final_replies: true, gate_prompts: true, auth_prompts: true}`, + destination = codec-grammar binding ref. Listed regardless of enrolled + device count (frontend surfaces the device state). + +2. **`crates/extensions/packages/web-push`** (`ironclaw_web_push_extension`, + layer: products) + - `manifest.toml` (v3): `[channel]` outbound-only (no ingress section, no + connection/pairing — the WebUI session is the identity), `[[channel.egress]]` + entries for the exact push-service hosts with + `injection = { type = "vapid_authorization" }` and tight body caps. + - `WebPushChannelAdapter`: `deliver()` loads the target user's + subscriptions (via the runtime slot, below), encrypts per subscription + through the domain crate, sends one `RestrictedEgress` POST per + subscription (TTL 24h; Urgency high when an `AuthPrompt` part is + present), classifies outcomes (≥1 accepted ⇒ `Sent { vendor_message_ref: None }`, + 404/410 prunes the subscription, all-retryable ⇒ `Retryable`, else + `Permanent`), renders `AuthPrompt` parts via `render_channel_auth_prompt`. + `inbound()` returns `ChannelError::Unsupported`. + - `WebPushPreferenceTargetCodec`: binding-ref grammar; every target is a + personal direct message (admits OAuth/auth prompts), actor = the user id. + - Package dep set = the channel-package four **plus `ironclaw_web_push`** — + justified the same way memory provider packages reach their domain crate: + the vendor-side state (the subscription registry — web push's analogue of + Slack's workspace) is host-local by nature. + - Runtime slot: the adapter is constructed by the CLI before storage + exists, so it holds a `WebPushRuntimeSlot` (from the domain crate) that + composition fills with the store handle at assembly (same late-bind shape + as the buffered trigger-poller hook). Until filled, deliver fails closed + with `ChannelError::Configuration`. + +### Generic host change (one, standard-based) + +A new declared egress credential-injection kind **`vapid_authorization`** +beside `header`/`query`/`path_placeholder`: the host transport resolves the +channel secret (JSON `{es256_private_key_pkcs8_b64url, public_key_b64url, subject}`), +computes `Authorization: vapid t=, +exp=now+12h, sub=subject>, k=` per RFC 8292, and injects it +host-side. The adapter never sees key bytes (it cannot even name the +`Authorization` header — `EgressHeader` forbids it). This is recipe +vocabulary for an IETF-standard auth scheme, not vendor code, implemented +once in the generic transport with `aws-lc-rs`. + +### Wiring + +- CLI binding table (`native_extensions.rs`): third `ChannelExtensionBinding` + (adapter + codec); slot handed to composition input. CLI dep-list + architecture pin + `crates/app/ironclaw_cli/AGENTS.md` sentence updated. +- Composition: seed the VAPID key material on boot when absent (generated by + the domain crate, stored where channel egress credentials resolve from); + build the subscription store on the filesystem plane; fill the slot; + `MutableOutboundDeliveryTargetRegistry::register_provider("web-push", …)`. +- Assistant: `web_push.subscribe` / `web_push.unsubscribe` product commands + + a `web_push_status` view (VAPID public key, enrolled-device summary), + following the `set_notification_channels` descriptor/service shape. +- WebUI: three routes under `/api/webchat/v2/web-push/…` (descriptors + + handlers under the `outbound` charter owner + CONTRACT.md rows). +- Frontend: `public/sw.js` (push + notificationclick, deep link to the app), + SW registration, notification panel: web-push row rendered like other + channels plus a per-browser "enable notifications in this browser" flow + (`Notification.requestPermission` → `pushManager.subscribe(VAPID)` → + subscribe command); device-state badges (unsupported / permission denied / + enrolled). `worker-src 'self'` added to the shell CSP. i18n keys in all 11 + locales. +- Model steering: `TRIGGER_CREATE_DESCRIPTION` currently says no web-app + target exists — update (+ its pinned `tool_surface_contract` test) and the + `delivery.md` prompt so routines can be steered to the web-push target. + +### Contract/doc corrections (dated) + +- `docs/reborn/contracts/communication-delivery-resolution.md` §5: "empty set + means web app only" → empty set means **no external notification route**; + the web app is selectable as the `web-push` catalog target. +- `docs/reborn/extension-runtime/overview.md` §5.4 note: the no-pseudo-target + statement stays true; add the web-push real-target clarification. +- `FEATURE_PARITY.md` PWA/Web Push row. +- New package README + family/crate table rows + target-tree doc for the two + new workspace members. + +## Two-lane contract: unchanged + +Completed fires still deliver nothing from the notifier (lane 1 owns the +result). Selecting the web-push notification channel yields gate/auth/failure +notices as pushes; "push me the result" routines pin the web-push catalog +target and the model delivers via `builtin.outbound_deliver` (lane 2) exactly +as with Slack/Telegram. Push acceptance (201/202 from the push service) is +transport acknowledgment, not device receipt; the adapter reports +`Sent { vendor_message_ref: None }` and never fabricates stronger evidence. + +## Test plan + +- Domain crate: RFC 8291 Appendix A vector; VAPID JWT round-trip verify; + store contract tests (CAS conflict, endpoint uniqueness, prune, scope + isolation); builder goldens; endpoint validation. +- Host transport: `vapid_authorization` injection unit + contract tests + (header shape, aud derivation, no key leak to adapter surface). +- Package: adapter conformance suite + deliver classification (Sent/prune/ + retry/permanent) over recording egress. +- Integration (`tests/integration/`): subscribe via product command → gated + trigger fire → notice push POST captured on the recording network substrate + (endpoint, `Authorization: vapid`, `Content-Encoding: aes128gcm`) + durable + delivery attempt; 410 response prunes the subscription; web-push target + selectable/deselectable through the notification-channels commands; the + `empty_notification_set_keeps_blocked_fire_in_app_only` scenario updated for + the new row's existence. +- Frontend vitest: panel row + enroll flow (mocked `navigator`), api client, + SW registration guard; root-paths/manifest pins. diff --git a/docs/reborn/contracts/communication-delivery-resolution.md b/docs/reborn/contracts/communication-delivery-resolution.md index 33b4495f8a4..b379cca596d 100644 --- a/docs/reborn/contracts/communication-delivery-resolution.md +++ b/docs/reborn/contracts/communication-delivery-resolution.md @@ -217,9 +217,13 @@ The record is keyed by scope (`DeliveryDefaultScope`, effectively - `notification_targets: Vec` — an explicit, user-configured **set** of 0..8 catalog targets (`NOTIFICATION_TARGETS_CAP`) that receive gate prompts, auth prompts, and failure notices for a - background/routine run with no live source route. Empty means "notification - channels are the web app only" — there is no dedicated in-app pseudo-target - to configure instead. + background/routine run with no live source route. Empty means "no external + notification route" — the run's reply still lands in its own thread, and + there is no dedicated in-app pseudo-target to configure instead. (✎ + 2026-08-08: the `web-push` catalog target — browser push to the user's + enrolled devices — is a real, provider-backed external target selectable in + this set like any channel target; it did not reintroduce the retired + in-app pseudo-target.) - `legacy_notification_target: Option` — read-migration input only (serialized under the historical wire name `final_reply_target` so a pre-migration row still deserializes). Nothing writes it. diff --git a/docs/reborn/contracts/triggers.md b/docs/reborn/contracts/triggers.md index 8f36bbf8c21..9d450765cac 100644 --- a/docs/reborn/contracts/triggers.md +++ b/docs/reborn/contracts/triggers.md @@ -527,7 +527,12 @@ after completion. An omitted selection inherits the sealed source route; an explicit target is re-resolved at send time and fails closed if it is removed, unpaired, revoked, stale, foreign, or otherwise unavailable. WebApp selection persists the result without external egress. Trigger execution itself still -does not choose, parse, or infer a destination. +does not choose, parse, or infer a destination. (✎ 2026-08-08: the retired +in-app "WebApp selection" sentence above describes the pre-#7157 stored-target +model; under the shipped two-lane model the `web-push` catalog target is a +real external destination — a routine that should notify the browser pins it +in the prompt's delivery step like any channel target, and the +notification-channel set fans gate/auth/failure notices to it when selected.) --- diff --git a/docs/reborn/extension-runtime/overview.md b/docs/reborn/extension-runtime/overview.md index 403dcc6db70..2db93fe821e 100644 --- a/docs/reborn/extension-runtime/overview.md +++ b/docs/reborn/extension-runtime/overview.md @@ -797,7 +797,12 @@ reply always lands in the conversation it belongs to, automatically, and rides neither tool — never sealed, redirected, or suppressed by a lane-2 call. There is no `web_app` pseudo-target: the WebUI already owns lane 1 for its own runs, so an empty notification-channel set simply means "no external -notification; the app is the surface." External targets pass through the +notification; the app is the surface." (✎ 2026-08-08: the `web-push` catalog +target — browser push notifications to the user's enrolled devices — is a +*real* external destination with genuine push-service egress, not a revival +of that pseudo-target; selecting it delivers OS notifications even while the +app is closed, while lane 1 still lands the run's reply in its thread.) +External targets pass through the coordinator and vendor adapter exactly like any other policy-class intent. This is a promotion, not an invention: the lower layer already exists diff --git a/docs/reborn/target-architecture/PROPOSAL.md b/docs/reborn/target-architecture/PROPOSAL.md index 6b245cb1b13..0483e799fe4 100644 --- a/docs/reborn/target-architecture/PROPOSAL.md +++ b/docs/reborn/target-architecture/PROPOSAL.md @@ -250,6 +250,8 @@ crates/ │ ├── ironclaw_identity ▣ [substrates] (renamed) external identity → stable UserId + user directory │ ├── ironclaw_llm ▣ [substrates] (narrowed) LlmProvider contract, providers, registry, decorators │ ├── ironclaw_trace_commons ▣ [substrates] (renamed) Trace Commons client: envelope/redaction/queue/credits +│ ├── ironclaw_web_push ▣ [substrates] Web Push (RFC 8030/8291/8292) records, encryption, +│ │ request planning (added 2026-08-08, browser channel) │ └── ironclaw_outbound ▣ [substrates] metadata-only outbound policy/state (sealed grants) ├── kernel/ ▢ the authority perimeter — nine crates, steady-state reached │ (the transitional tenth, ironclaw_run_state, was deleted by #6696 on 2026-07-29) @@ -305,6 +307,8 @@ crates/ │ │ filesystem backend (moved from domains/, amended 2026-07-29) │ ├── mem0/ ▣ ironclaw_memory_mem0 [products] — [memory] provider surface, external │ │ mem0 REST backend (moved from domains/, amended 2026-07-29) +│ ├── web-push/ ▣ ironclaw_web_push_extension [products] — outbound-only browser-push +│ │ ChannelAdapter + codec + target provider (added 2026-08-08) │ └── / ▢ data-only packages (github, gmail, google-*, web-access, notion-mcp, │ nearai-mcp, …): manifest.toml, prompts/, schemas/, wasm/, ◇ wasm-src/ ├── product/ ▢ first-party userland above the kernel diff --git a/scripts/ci/composition-budget.toml b/scripts/ci/composition-budget.toml index d0b7426693e..f242b876b42 100644 --- a/scripts/ci/composition-budget.toml +++ b/scripts/ci/composition-budget.toml @@ -153,7 +153,14 @@ observed_date = "2026-08-07" # with `bash scripts/ci/check-composition-budget.sh`. # Union re-measured 40432 -> 40747 on 2026-08-07 after merging #7157's delivery # refactor with #7214's sandbox profile and binding assembly. -loc_ceiling = 40747 +# Raised 40747 -> 41035 on 2026-08-08 for the web-push channel's assembly: +# `assemble_web_push` (subscription-store construction, runtime-slot install, +# VAPID credential seeding + read-back, manifest-derived push-host extraction) +# plus the binding-borne outbound-target-provider registration loop and the +# input/plumbing fields. All of it is service-graph assembly — record grammar, +# crypto, and protocol behavior live in ironclaw_web_push / the channel +# package / host_runtime's egress injector. +loc_ceiling = 41035 # Working slack for in-flight PRs. Deliberately small: the inflow this gate # exists to catch was +619 lines, and a tolerance that would have absorbed it # is a gate that constrains nothing. A change adding more than this to @@ -164,7 +171,7 @@ loc_tolerance = 150 loc_nudge_slack = 200 # Informational — observed when this file was last updated. Not consulted for # the pass/fail decision. -loc_observed = 40747 +loc_observed = 41035 loc_observed_date = "2026-08-07" # --- Dispatch (Arc) ratchet ------------------------------------------ diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 734830483f1..1591aee34ee 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -238,6 +238,7 @@ One thread, whole real turn. Grouped by what the user experiences. | Scheduled-origin runs carry their origin into persisted state | `triggered_submit.rs` | | The test harness's runtime wiring stays field-identical to production's | `wiring_parity.rs` | | WebUI v2 routes work over the real services facade | `webui_v2_product_api.rs`, `webui_v2_router_smoke.rs` | +| Enroll/refresh/remove a browser for web push over the real routes — advertised VAPID key, endpoint redacted to its push-service host, undeclared push hosts rejected, and the `web-push` catalog row selectable through the same notification-channels wire as every vendor channel | `webui_v2_product_api.rs::web_push_enrollment_and_notification_channel_round_trip_through_production_facade` | | Identity resolution runs on the coverage lane | `identity_resolution_smoke.rs` | One of the 55 registered bins, `delivery_user_journeys.rs`, holds the explicit @@ -254,6 +255,8 @@ channel-delivery journeys (two-lane model): | Have a conditional fire that calls no delivery tool produce zero outbound attempts | `conditional_fire_with_no_delivery_call_produces_zero_attempts` | | Get blocked-fire notices fanned out to every notification channel, first approve wins | `blocked_fire_fans_out_and_first_approve_wins` | | Keep a blocked fire app-only when the notification-channel set is empty | `empty_notification_set_keeps_blocked_fire_in_app_only` | +| Enroll a browser and get a blocked fire's gate notice as a real Web Push (encrypted `aes128gcm` body, host-injected `Authorization: vapid`, one POST to the enrolled endpoint) while the run stays parked | `blocked_fire_pushes_web_push_notice_to_enrolled_browser` | +| Have a dead browser subscription (push service answers `410 Gone`) pruned after one notice attempt | `gone_push_subscription_is_pruned_after_notice_attempt` | --- diff --git a/tests/integration/delivery_user_journeys.rs b/tests/integration/delivery_user_journeys.rs index 59891202b59..a1829a25cdc 100644 --- a/tests/integration/delivery_user_journeys.rs +++ b/tests/integration/delivery_user_journeys.rs @@ -1915,3 +1915,366 @@ async fn empty_notification_set_keeps_blocked_fire_in_app_only() { "the hold keeps its actionable gate ref" ); } + +// ── Web-push notice journeys (browser channel) ───────────────────────────── + +/// Push endpoints on the web-push manifest's declared FCM host. The reserved +/// `gone-subscription-token` suffix mirrors +/// `harness/profiles/extension.rs::WEB_PUSH_GONE_ENDPOINT_TOKEN`: the +/// profile's vendor router answers it `410 Gone` (every other push POST gets +/// `201 Created`). +const WEB_PUSH_LIVE_ENDPOINT: &str = "https://fcm.googleapis.com/fcm/send/live-subscription-token"; +const WEB_PUSH_GONE_ENDPOINT: &str = "https://fcm.googleapis.com/fcm/send/gone-subscription-token"; +const WEB_PUSH_TARGET_ID: &str = "web-push"; + +/// Enroll one browser for the harness creator through the REAL WebUI route +/// (`POST /api/webchat/v2/web-push/subscriptions`) over the composed +/// runtime's production product surface — the same path the browser panel +/// drives. +async fn enroll_web_push_browser( + harness: &RebornIntegrationHarness, + services: &RebornRuntime, + endpoint: &str, +) { + use base64::Engine as _; + let webui = services + .product_surface(None) + .expect("composed runtime builds the production product surface"); + // The enrollment owner must be the fire CREATOR (the notifier resolves + // notification targets for `creator_user_id` = the binding actor). + let caller = ironclaw_product_contracts::surface::ProductSurfaceCaller::new( + harness.binding.tenant_id.clone(), + harness.binding.actor_user_id.clone(), + harness.binding.agent_id.clone(), + harness.binding.project_id.clone(), + ); + let point = ironclaw_web_push::generate_vapid_key_material("mailto:browser@example.com") + .expect("generate a valid P-256 point") + .public_key_b64url; + let (status, body) = reborn_support::webui_mount::post_json( + reborn_support::webui_mount::mount_webui_v2_router(webui, caller), + "/api/webchat/v2/web-push/subscriptions", + json!({ + "endpoint": endpoint, + "keys": { + "p256dh": point, + "auth": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([9u8; 16]), + }, + "user_agent": "JourneyBrowser/1.0", + }), + ) + .await; + assert_eq!( + status, + axum::http::StatusCode::OK, + "enroll response: {body}" + ); + assert_eq!(body["outcome"], "enrolled", "{body}"); +} + +/// The web-push notifier: [`background_run_notifier`]'s services with the +/// browser channel's codec beside Slack's, so the creator's `web-push` +/// notification target decodes. +fn web_push_background_run_notifier( + harness: &RebornIntegrationHarness, + services: &RebornRuntime, +) -> ironclaw_assistant::TriggeredRunDeliveryDriver { + ironclaw_assistant::TriggeredRunDeliveryDriver::with_settings( + slack_run_delivery_services(harness, services), + ironclaw_assistant::RunDeliverySettings { + poll_interval: Duration::from_millis(5), + max_wait: Duration::from_secs(10), + max_concurrent_deliveries: std::num::NonZeroUsize::new(4).expect("non-zero"), + max_pending_deliveries: std::num::NonZeroUsize::new(8).expect("non-zero"), + }, + services + .triggered_run_delivery_store_for_test() + .expect("composed runtime exposes the triggered-delivery outcome store"), + Arc::new(vec![ + Arc::new(ironclaw_slack_extension::SlackPreferenceTargetCodec) + as Arc, + Arc::new(ironclaw_web_push_extension::WebPushPreferenceTargetCodec) + as Arc, + ]) as Arc, + slack_agent(), + ) +} + +/// Bounded-poll the wire recorder for push-service POSTs to `endpoint`. +async fn wait_for_push_posts( + harness: &RebornIntegrationHarness, + endpoint: &str, + expected: usize, +) -> Vec { + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + let posts: Vec<_> = harness + .captured_network_requests_for_test() + .into_iter() + .filter(|request| request.url.starts_with(endpoint)) + .collect(); + if posts.len() >= expected { + return posts; + } + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {expected} push POST(s) to {endpoint}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// The browser subscriptions currently enrolled for the harness creator, +/// read back through the SAME production status route the panel uses. +async fn web_push_subscription_count( + harness: &RebornIntegrationHarness, + services: &RebornRuntime, +) -> u64 { + let webui = services + .product_surface(None) + .expect("composed runtime builds the production product surface"); + let caller = ironclaw_product_contracts::surface::ProductSurfaceCaller::new( + harness.binding.tenant_id.clone(), + harness.binding.actor_user_id.clone(), + harness.binding.agent_id.clone(), + harness.binding.project_id.clone(), + ); + let (status, body) = reborn_support::webui_mount::get_json( + reborn_support::webui_mount::mount_webui_v2_router(webui, caller), + "/api/webchat/v2/web-push/status", + ) + .await; + assert_eq!( + status, + axum::http::StatusCode::OK, + "status response: {body}" + ); + body["subscription_count"] + .as_u64() + .expect("status carries subscription_count") +} + +/// A blocked routine fire's gate notice reaches an enrolled browser as a +/// real Web Push: encrypted `aes128gcm` body, host-injected +/// `Authorization: vapid` (the adapter cannot set that header), protocol +/// headers, one POST to the exact enrolled endpoint — while the run parks on +/// its gate and the notice lands in the durable attempt ledger. +#[tokio::test(flavor = "multi_thread")] +async fn blocked_fire_pushes_web_push_notice_to_enrolled_browser() { + let group = RebornIntegrationGroup::extension_delivery_with_web_push() + .await + .expect("delivery-with-web-push group builds"); + let services = reborn_services(&group); + let harness = group + .thread("conv-web-push-notice") + .build() + .await + .expect("web-push notice thread builds"); + + enroll_web_push_browser(&harness, services, WEB_PUSH_LIVE_ENDPOINT).await; + seed_notification_channel(&harness, services, WEB_PUSH_TARGET_ID).await; + gate_the_write(&group, &harness).await; + + let submission = harness + .submit_triggered_turn_scripted( + GATED_FIRE_PROMPT, + [ + RebornScriptedReply::tool_call( + "builtin.write_file", + json!({"path": "/workspace/web-push-report.txt", "content": "nightly deploy report"}), + ), + RebornScriptedReply::text(GATED_FIRE_REPLY), + ], + ) + .await + .expect("gated fire submits"); + + let notifier = web_push_background_run_notifier(&harness, services); + notifier + .on_trigger_submitted(notifier_request(&harness, &submission, GATED_FIRE_PROMPT)) + .await; + + let blocked = harness + .wait_for_status_in_scope( + &submission.turn_scope, + submission.run_id, + TurnStatus::BlockedApproval, + ) + .await + .expect("the gated fire parks on a real approval gate"); + let gate_ref = blocked + .gate_ref + .expect("blocked triggered run carries a gate ref"); + + // Wire seam: exactly one push POST to the enrolled endpoint, carrying the + // host-injected VAPID authorization and the RFC 8188/8291 framing. + let posts = wait_for_push_posts(&harness, WEB_PUSH_LIVE_ENDPOINT, 1).await; + assert_eq!(posts.len(), 1, "one enrolled browser, one push POST"); + let post = &posts[0]; + let header = |name: &str| { + post.headers + .iter() + .find(|(header, _)| header.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.clone()) + }; + let authorization = header("authorization") + .expect("host-side VAPID injection must add the authorization header"); + assert!( + authorization.starts_with("vapid t="), + "RFC 8292 vapid scheme, host-computed: {authorization}" + ); + assert!( + authorization.contains(", k="), + "the advertised application-server key rides the header: {authorization}" + ); + assert_eq!( + header("content-encoding").as_deref(), + Some("aes128gcm"), + "RFC 8291 content encoding" + ); + assert!(header("ttl").is_some(), "push TTL header present"); + assert!( + post.body.len() >= 87, + "aes128gcm header + at least one sealed byte; got {}", + post.body.len() + ); + assert_eq!( + post.body[20], 65, + "RFC 8188 idlen = uncompressed P-256 point" + ); + + // Durable seam: the notice landed in the attempt ledger as a delivered + // gate prompt, and the run is STILL parked on its gate. + let (outbound_store, _, _, _, _) = services + .outbound_delivery_stores_for_test() + .expect("outbound stores"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + let delivered_gate_prompts = loop { + let attempts = outbound_store + .list_delivery_attempts(submission.turn_scope.clone()) + .await + .expect("list delivery attempts"); + let delivered = attempts + .iter() + .filter(|attempt| { + attempt.status == OutboundDeliveryStatus::Delivered + && attempt.candidate.kind == OutboundPushKind::GateRequired + }) + .count(); + if delivered >= 1 { + break delivered; + } + assert!( + tokio::time::Instant::now() < deadline, + "no delivered gate-prompt attempt recorded; got {:?}", + attempts + .iter() + .map(|attempt| (attempt.status, attempt.candidate.kind)) + .collect::>() + ); + tokio::time::sleep(Duration::from_millis(10)).await; + }; + assert_eq!(delivered_gate_prompts, 1, "one browser notice delivered"); + let still_blocked = harness + .wait_for_status_in_scope( + &submission.turn_scope, + submission.run_id, + TurnStatus::BlockedApproval, + ) + .await + .expect("the push notice must not resolve the gate"); + assert!(still_blocked.gate_ref.is_some(), "gate still pending"); + + // Settle the group: approve and let the fire finish. + harness + .approve_gate_in_scope(&submission.turn_scope, submission.run_id, &gate_ref) + .await + .expect("approving resumes the run"); + harness + .wait_for_status_in_scope( + &submission.turn_scope, + submission.run_id, + TurnStatus::Completed, + ) + .await + .expect("the resumed fire completes"); +} + +/// A push service answering `410 Gone` prunes the dead subscription: the +/// notice attempt reaches the wire once, then the browser disappears from +/// the caller's enrollment set so future sends stop trying. +#[tokio::test(flavor = "multi_thread")] +async fn gone_push_subscription_is_pruned_after_notice_attempt() { + let group = RebornIntegrationGroup::extension_delivery_with_web_push() + .await + .expect("delivery-with-web-push group builds"); + let services = reborn_services(&group); + let harness = group + .thread("conv-web-push-prune") + .build() + .await + .expect("web-push prune thread builds"); + + enroll_web_push_browser(&harness, services, WEB_PUSH_GONE_ENDPOINT).await; + assert_eq!(web_push_subscription_count(&harness, services).await, 1); + seed_notification_channel(&harness, services, WEB_PUSH_TARGET_ID).await; + gate_the_write(&group, &harness).await; + + let submission = harness + .submit_triggered_turn_scripted( + GATED_FIRE_PROMPT, + [ + RebornScriptedReply::tool_call( + "builtin.write_file", + json!({"path": "/workspace/web-push-prune.txt", "content": "nightly deploy report"}), + ), + RebornScriptedReply::text(GATED_FIRE_REPLY), + ], + ) + .await + .expect("gated fire submits"); + + let notifier = web_push_background_run_notifier(&harness, services); + notifier + .on_trigger_submitted(notifier_request(&harness, &submission, GATED_FIRE_PROMPT)) + .await; + + let blocked = harness + .wait_for_status_in_scope( + &submission.turn_scope, + submission.run_id, + TurnStatus::BlockedApproval, + ) + .await + .expect("the gated fire parks on a real approval gate"); + + // The dead endpoint was attempted once, answered 410, and pruned. + let posts = wait_for_push_posts(&harness, WEB_PUSH_GONE_ENDPOINT, 1).await; + assert_eq!(posts.len(), 1, "the dead subscription is attempted once"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + if web_push_subscription_count(&harness, services).await == 0 { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the 410 subscription was never pruned" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + + // Settle the group. + let gate_ref = blocked.gate_ref.expect("gate ref"); + harness + .approve_gate_in_scope(&submission.turn_scope, submission.run_id, &gate_ref) + .await + .expect("approving resumes the run"); + harness + .wait_for_status_in_scope( + &submission.turn_scope, + submission.run_id, + TurnStatus::Completed, + ) + .await + .expect("the resumed fire completes"); +} diff --git a/tests/integration/support/group_constructors.rs b/tests/integration/support/group_constructors.rs index c6189383bfc..d82714aa2c5 100644 --- a/tests/integration/support/group_constructors.rs +++ b/tests/integration/support/group_constructors.rs @@ -123,6 +123,17 @@ impl RebornIntegrationGroup { Self::builder().extension_delivery_with_gated_write().await } + /// [`Self::extension_delivery_with_gated_write`] PLUS the complete + /// web-push channel: deployment binding (adapter + codec + catalog + /// provider) around one late-bound runtime slot, the slot on the + /// composition input (so `assemble_web_push` installs the subscription + /// store and seeds the VAPID credential), the bundled manifest, and a + /// vendor router answering push-service POSTs with `201` (`410` for the + /// reserved dead-subscription token). + pub async fn extension_delivery_with_web_push() -> HarnessResult { + Self::builder().extension_delivery_with_web_push().await + } + /// Same group as [`Self::extension_lifecycle`], with a Google OAuth /// backend configured at composition time. Proves the /// provider-instance readiness check does not false-positive once an @@ -574,6 +585,36 @@ impl RebornIntegrationGroupBuilder { self.into_group(base, capability).await } + /// Build a delivery group with the web-push channel wired. See + /// [`RebornIntegrationGroup::extension_delivery_with_web_push`]. + pub async fn extension_delivery_with_web_push( + mut self, + ) -> HarnessResult { + let base = self.build_base().await?; + let (web_push_profile, _web_push_slot) = super::super::harness::profiles::extension::extension_delivery_with_web_push_tools_profile()?; + let host_runtime = build_group_capability_with_base(web_push_profile, &base) + .await? + .with_run_owner_scoped_capability_dispatch(); + let scope = &base.product_harness.scope; + let channel_connection = + ironclaw_composition::test_support::build_channel_connection_for_test( + host_runtime.reborn_services_for_test().ok_or( + "extension_delivery_with_web_push harness is missing its RebornServices bundle", + )?, + ironclaw_composition::test_support::ChannelConnectionTestConfig { + tenant_id: scope.tenant_id.as_str().to_string(), + agent_id: scope + .agent_id + .as_ref() + .map(|agent| agent.as_str().to_string()) + .ok_or("group product scope is missing an agent id")?, + }, + )?; + self.channel_connection = Some(Arc::new(channel_connection)); + let capability = GroupCapability::HostRuntime(Arc::new(host_runtime)); + self.into_group(base, capability).await + } + /// Build a visibility-probe group. See /// [`RebornIntegrationGroup::extension_visibility_probe`]. pub async fn extension_visibility_probe(self) -> HarnessResult { diff --git a/tests/integration/support/harness/mod.rs b/tests/integration/support/harness/mod.rs index ce716389503..24037184666 100644 --- a/tests/integration/support/harness/mod.rs +++ b/tests/integration/support/harness/mod.rs @@ -698,6 +698,8 @@ impl HostRuntimeCapabilityHarness { fixture_extension_dirs, native_extension_factories, channel_extension_bindings, + web_push_runtime_slot, + extra_first_party_bundles, recording_network_egress, google_oauth_backend_for_test, sandboxed_shell, @@ -763,6 +765,18 @@ impl HostRuntimeCapabilityHarness { input = input.with_runtime_policy(runtime_policy); } input = input.with_bundled_first_party_for_test(); + if !extra_first_party_bundles.is_empty() { + // Mirror the binary: inventory bundles first, binary-table + // extras (web-push) appended. `with_first_party_bundles` + // replaces, so rebuild the full list. + let mut bundles = + ironclaw_extension_host::test_support::first_party_bundles_from_inventory(); + bundles.extend(extra_first_party_bundles); + input = input.with_first_party_bundles(bundles); + } + if let Some(slot) = web_push_runtime_slot { + input = input.with_web_push_runtime_slot(slot); + } if !native_extension_factories.is_empty() { input = input.with_native_extension_factories(native_extension_factories); } diff --git a/tests/integration/support/harness/options.rs b/tests/integration/support/harness/options.rs index 578d1569d5e..e0e61d2e1de 100644 --- a/tests/integration/support/harness/options.rs +++ b/tests/integration/support/harness/options.rs @@ -81,6 +81,15 @@ pub(crate) struct HostRuntimeHarnessOptions { /// extensions (`RebornHostBindings::with_channel_extension_bindings` — the /// same seam the binary uses for Slack's WASM-runtime package). pub(crate) channel_extension_bindings: Vec, + /// The web-push channel's late-bound runtime slot, mirrored onto the + /// composition input (`RebornHostBindings::with_web_push_runtime_slot`) + /// the way the binary's serve assembly passes it. + pub(crate) web_push_runtime_slot: Option, + /// Extra first-party manifest bundles appended AFTER the + /// `extension_support` inventory — the harness mirror of the bundles the + /// BINARY adds in `ironclaw_cli::first_party::bundles` (web-push ships + /// from the binary's table, not the shared inventory). + pub(crate) extra_first_party_bundles: Vec, /// Typed handle for the recording network egress when the profile wants /// `captured_network_requests` assertions (the dyn seam alone loses the /// recorder type). @@ -152,6 +161,8 @@ impl HostRuntimeHarnessOptions { fixture_extension_dirs: Vec::new(), native_extension_factories: Vec::new(), channel_extension_bindings: Vec::new(), + web_push_runtime_slot: None, + extra_first_party_bundles: Vec::new(), recording_network_egress: None, project_service_fault_injection: false, durable_capability_io: false, @@ -268,6 +279,49 @@ impl HostRuntimeHarnessOptions { self } + /// Wire the complete web-push channel the way the binary does: the + /// deployment binding (adapter + codec + catalog target provider) around + /// one late-bound runtime slot, the slot handed to composition so + /// `assemble_web_push` installs storage + seeds the VAPID credential, and + /// the package manifest bundled so the deployment-channel registry + /// resolves the channel's egress declarations. + pub(crate) fn with_web_push_channel_extension( + mut self, + slot: ironclaw_web_push::WebPushRuntimeSlot, + ) -> Self { + self.channel_extension_bindings + .push(ironclaw_composition::ChannelExtensionBinding { + extension_id: ironclaw_host_api::ids::ExtensionId::from_trusted( + ironclaw_web_push::WEB_PUSH_EXTENSION_ID.to_string(), + ), + adapter: std::sync::Arc::new( + ironclaw_web_push_extension::WebPushChannelAdapter::new(slot.clone()), + ), + preference_target_codec: Some(std::sync::Arc::new( + ironclaw_web_push_extension::WebPushPreferenceTargetCodec, + )), + outbound_target_provider: Some(std::sync::Arc::new( + ironclaw_web_push_extension::WebPushOutboundTargetProvider::new(), + )), + }); + self.extra_first_party_bundles + .push(ironclaw_extension_host::FirstPartyPackageBundle { + id: ironclaw_web_push::WEB_PUSH_EXTENSION_ID.to_string(), + display_name: "Browser notifications".to_string(), + manifest_toml: ironclaw_web_push_extension::MANIFEST.to_string(), + assets: vec![ironclaw_extension_host::FirstPartyPackageAsset { + path: "manifest.toml".to_string(), + bytes: ironclaw_web_push_extension::MANIFEST.as_bytes().to_vec(), + }], + onboarding: None, + oauth_setup: None, + trust_effects: None, + search_aliases: Vec::new(), + }); + self.web_push_runtime_slot = Some(slot); + self + } + pub(crate) fn with_activated_bundled_extension(mut self, package: ExtensionPackage) -> Self { self.activate_bundled_extensions_for_test .push((package, None)); diff --git a/tests/integration/support/harness/profiles/extension.rs b/tests/integration/support/harness/profiles/extension.rs index faf5d6e4691..cdc14231cd4 100644 --- a/tests/integration/support/harness/profiles/extension.rs +++ b/tests/integration/support/harness/profiles/extension.rs @@ -1915,6 +1915,7 @@ fn slack_channel_extension_binding() -> ironclaw_composition::ChannelExtensionBi preference_target_codec: Some(Arc::new( ironclaw_slack_extension::SlackPreferenceTargetCodec, )), + outbound_target_provider: None, } } @@ -1932,6 +1933,7 @@ fn telegram_channel_extension_binding() -> ironclaw_composition::ChannelExtensio preference_target_codec: Some(Arc::new( ironclaw_telegram_extension::TelegramPreferenceTargetCodec, )), + outbound_target_provider: None, } } @@ -1939,6 +1941,50 @@ pub(crate) async fn extension_delivery_tools() -> HarnessResult HarnessResult<(ToolsProfile, ironclaw_web_push::WebPushRuntimeSlot)> { + let mut profile = extension_delivery_with_gated_write_tools_profile()?; + let slot = ironclaw_web_push::WebPushRuntimeSlot::new(); + let network_egress = Arc::new( + RecordingNetworkHttpEgress::with_body(br#"{"ok":true}"#.to_vec()) + .with_vendor_router(web_push_delivery_vendor_router()), + ); + profile.options = profile + .options + .with_web_push_channel_extension(slot.clone()) + .with_recording_network_egress(network_egress); + Ok((profile, slot)) +} + +/// The delivery vendor router extended for push services: any POST to an +/// endpoint on the web-push manifest's declared hosts answers the way a +/// real push service does — `201 Created` with an empty body (RFC 8030), or +/// `410 Gone` for the reserved dead-subscription token. +fn web_push_delivery_vendor_router() -> Arc { + Arc::new(move |request: &ironclaw_network::NetworkHttpRequest| { + if request.url.starts_with("https://fcm.googleapis.com/") { + if request.url.contains(WEB_PUSH_GONE_ENDPOINT_TOKEN) { + return Some((410, Vec::new())); + } + return Some((201, Vec::new())); + } + delivery_vendor_router(request) + }) +} + // ── Standard messaging op conformance (standardized messaging framework, // task 7) ──────────────────────────────────────────────────────────────── // diff --git a/tests/integration/webui_v2_product_api.rs b/tests/integration/webui_v2_product_api.rs index a269110158d..8a803e3aca1 100644 --- a/tests/integration/webui_v2_product_api.rs +++ b/tests/integration/webui_v2_product_api.rs @@ -2369,3 +2369,219 @@ async fn execute_status_command_reflects_owned_thread_and_hides_foreign_thread_e "a foreign thread must be indistinguishable from a nonexistent one" ); } + +// ── Web-push enrollment + notification-channel wire (browser channel) ────── + +/// The full web-push channel over the PRODUCTION composition: manifest +/// bundle, deployment binding, `assemble_web_push` (store install + VAPID +/// seeding + manifest-derived host allowlist), the product surface wiring, +/// and the real WebUI routes. +fn web_push_build_extras( + input: ironclaw_composition::RebornHostBindings, + slot: &ironclaw_web_push::WebPushRuntimeSlot, +) -> ironclaw_composition::RebornHostBindings { + let mut bundles = ironclaw_extension_host::test_support::first_party_bundles_from_inventory(); + bundles.push(ironclaw_extension_host::FirstPartyPackageBundle { + id: ironclaw_web_push::WEB_PUSH_EXTENSION_ID.to_string(), + display_name: "Browser notifications".to_string(), + manifest_toml: ironclaw_web_push_extension::MANIFEST.to_string(), + assets: vec![ironclaw_extension_host::FirstPartyPackageAsset { + path: "manifest.toml".to_string(), + bytes: ironclaw_web_push_extension::MANIFEST.as_bytes().to_vec(), + }], + onboarding: None, + oauth_setup: None, + trust_effects: None, + search_aliases: Vec::new(), + }); + input + .with_first_party_bundles(bundles) + .with_channel_extension_bindings(vec![ironclaw_composition::ChannelExtensionBinding { + extension_id: ExtensionId::from_trusted( + ironclaw_web_push::WEB_PUSH_EXTENSION_ID.to_string(), + ), + adapter: Arc::new(ironclaw_web_push_extension::WebPushChannelAdapter::new( + slot.clone(), + )), + preference_target_codec: Some(Arc::new( + ironclaw_web_push_extension::WebPushPreferenceTargetCodec, + )), + outbound_target_provider: Some(Arc::new( + ironclaw_web_push_extension::WebPushOutboundTargetProvider::new(), + )), + }]) + .with_web_push_runtime_slot(slot.clone()) +} + +/// A browser-shaped subscription body: a REAL P-256 point (any valid point — +/// generated through the domain crate's own keygen) plus a 16-byte auth +/// secret. +fn browser_subscription_body(endpoint: &str) -> Value { + use base64::Engine as _; + let point = ironclaw_web_push::generate_vapid_key_material("mailto:browser@example.com") + .expect("generate a valid P-256 point") + .public_key_b64url; + serde_json::json!({ + "endpoint": endpoint, + "keys": { + "p256dh": point, + "auth": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 16]), + }, + "user_agent": "TestBrowser/1.0", + }) +} + +#[tokio::test] +async fn web_push_enrollment_and_notification_channel_round_trip_through_production_facade() { + use base64::Engine as _; + + let root = tempdir().expect("runtime storage tempdir"); + let storage_root = root.path().join("local-dev"); + let tenant_id = TenantId::new("webui-webpush-tenant").expect("tenant id"); + let agent_id = AgentId::new("webui-webpush-agent").expect("agent id"); + let user_id = UserId::new("webui-webpush-user").expect("user id"); + let slot = ironclaw_web_push::WebPushRuntimeSlot::new(); + let input = web_push_build_extras( + ironclaw_composition::local_filesystem_build_input(user_id.as_str(), storage_root.clone()) + .with_local_runtime_identity(tenant_id.clone(), agent_id.clone()) + .with_runtime_policy(standalone_runtime_policy().expect("local-dev policy")) + .with_bundled_first_party_for_test() + .with_network_http_egress_for_test(Arc::new( + reborn_support::harness::RecordingNetworkHttpEgress::with_body(Vec::new()), + )), + &slot, + ); + let runtime = build_reborn_runtime( + RebornRuntimeInput::from_build_input(input) + .with_identity(RebornRuntimeIdentity { + tenant_id: tenant_id.as_str().to_string(), + agent_id: agent_id.as_str().to_string(), + source_binding_id: "webui-webpush-source".to_string(), + reply_target_binding_id: "webui-webpush-reply".to_string(), + }) + .with_model_gateway_override(Arc::new(BudgetTestGateway::with_constant( + "unused", 0, 0, + ))), + ) + .await + .expect("production Reborn runtime builds"); + assert!( + slot.is_installed(), + "composition must install the web-push runtime into the binary's slot" + ); + let webui = runtime + .product_surface(None) + .expect("production product surface builds"); + let caller = ProductSurfaceCaller::new( + tenant_id.clone(), + user_id.clone(), + Some(agent_id.clone()), + None, + ); + let router = || mount_webui_v2_router(Arc::clone(&webui), caller.clone()); + const ENDPOINT: &str = "https://fcm.googleapis.com/fcm/send/test-token-1"; + + // Status before any enrollment: a well-formed advertised VAPID key + // (seeded by composition on first boot), zero browsers. + let (status, body) = get_json(router(), "/api/webchat/v2/web-push/status").await; + assert_eq!(status, StatusCode::OK, "status response: {body}"); + let vapid_public_key = body["vapid_public_key"] + .as_str() + .expect("status carries the vapid key") + .to_string(); + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&vapid_public_key) + .expect("vapid key is base64url"); + assert_eq!(decoded.len(), 65, "uncompressed P-256 point"); + assert_eq!(decoded[0], 0x04, "uncompressed point marker"); + assert_eq!(body["subscription_count"], 0, "{body}"); + + // The catalog offers the browser channel beside the vendor channels. + let (status, body) = get_json(router(), "/api/webchat/v2/outbound/targets").await; + assert_eq!(status, StatusCode::OK, "targets response: {body}"); + let targets = body["targets"].as_array().expect("targets array"); + let web_push_target = targets + .iter() + .find(|entry| entry["target"]["target_id"] == "web-push") + .unwrap_or_else(|| panic!("web-push target missing from the catalog: {body}")); + assert_eq!(web_push_target["target"]["channel"], "web-push", "{body}"); + assert_eq!( + web_push_target["target"]["display_name"], "Web app", + "{body}" + ); + + // Enroll → enrolled; identical repeat → refreshed. + let subscription = browser_subscription_body(ENDPOINT); + let (status, body) = post_json( + router(), + "/api/webchat/v2/web-push/subscriptions", + subscription.clone(), + ) + .await; + assert_eq!(status, StatusCode::OK, "subscribe response: {body}"); + assert_eq!(body["outcome"], "enrolled", "{body}"); + let (status, body) = post_json( + router(), + "/api/webchat/v2/web-push/subscriptions", + subscription, + ) + .await; + assert_eq!(status, StatusCode::OK, "re-subscribe response: {body}"); + assert_eq!(body["outcome"], "refreshed", "{body}"); + + // Status redacts the endpoint capability URL to its push-service host. + let (status, body) = get_json(router(), "/api/webchat/v2/web-push/status").await; + assert_eq!(status, StatusCode::OK, "status response: {body}"); + assert_eq!(body["subscription_count"], 1, "{body}"); + assert_eq!( + body["subscriptions"][0]["endpoint_host"], "fcm.googleapis.com", + "{body}" + ); + assert!( + !body.to_string().contains(ENDPOINT), + "the full endpoint capability URL must never leave the backend: {body}" + ); + + // An endpoint on a host the manifest never declared fails closed. + let (status, body) = post_json( + router(), + "/api/webchat/v2/web-push/subscriptions", + browser_subscription_body("https://evil.example.com/x"), + ) + .await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "undeclared push host must be rejected: {body}" + ); + + // Selecting the browser channel persists through the SAME + // notification-channels wire every vendor channel uses. + let (status, body) = post_json( + router(), + "/api/webchat/v2/outbound/notification-channels", + serde_json::json!({"target_ids": ["web-push"]}), + ) + .await; + assert_eq!(status, StatusCode::OK, "set channels response: {body}"); + let (status, body) = get_json(router(), "/api/webchat/v2/outbound/notification-channels").await; + assert_eq!(status, StatusCode::OK, "get channels response: {body}"); + assert_eq!(body["channels"][0]["target_id"], "web-push", "{body}"); + assert_eq!(body["channels"][0]["status"], "available", "{body}"); + + // Unenroll; the browser disappears from the caller's status. + let (status, body) = post_json( + router(), + "/api/webchat/v2/web-push/subscriptions/remove", + serde_json::json!({"endpoint": ENDPOINT}), + ) + .await; + assert_eq!(status, StatusCode::OK, "remove response: {body}"); + assert_eq!(body["removed"], true, "{body}"); + let (status, body) = get_json(router(), "/api/webchat/v2/web-push/status").await; + assert_eq!(status, StatusCode::OK, "status response: {body}"); + assert_eq!(body["subscription_count"], 0, "{body}"); + + drop(webui); + runtime.shutdown().await.expect("runtime shuts down"); +} From ef5ee69c6d28a85fa026a6a38a62d1a9754d539e Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sat, 8 Aug 2026 19:28:06 -0400 Subject: [PATCH 02/13] =?UTF-8?q?fix(web-push):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20redact=20VAPID=20secret,=20byte-budget=20payload,?= =?UTF-8?q?=20account-scoped=20enrollment,=20sanitized=20store=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triage of the CodeRabbit + IronLoop review on #7398: - Secret exposure (Critical): hand-written redacting `Debug` on `VapidCredentialMaterialV1` and `GeneratedVapidKeyMaterial` so the ES256 private key can never reach a log, panic, or `{:?}`. - VAPID material (High): composition now ensures-then-reads-back the canonical stored keypair so multi-replica cold-start converges on one signing key and the advertised applicationServerKey matches it; material shape is validated at boot and again before egress signing. - Payload budget (Medium): notification body is trimmed by serialized-JSON bytes, not character count, so multi-byte content can't blow the single-record push budget (+ regression test). - Error hygiene (Medium): `WebPushError::Store` is a fixed sanitized category; the backend cause is logged server-side, never rendered into the boundary error. - Account-scoped enrollment (Medium): status projects an `endpoint_digest` (SHA-256 hex) so a shared browser profile distinguishes "enrolled here" from "enrolled for another account" without the endpoint URL leaving the backend; the frontend correlates on it and only offers destructive disable when verified. - Deep-link/tag grammar enforced in the owning crate; SW validates same-origin before navigating; subject parsed via `url`; concurrent-writer CAS test; registry key derived from the extension-id constant; parse-cause preserved; doc/count corrections. The manifest keeps the `web_push_vapid` field (a channel egress credential handle must be declared in [admin_configuration]); it stays host-seeded and not operator-supplied, with the rotation caveat documented. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 + .../tests/reborn_dependency_boundaries.rs | 11 +- .../factory/production_backend_assembly.rs | 113 +++++++++------- .../contracts/ironclaw_host_api/src/http.rs | 92 ++++++++++++- .../src/product_wire.rs | 8 +- crates/domains/ironclaw_web_push/Cargo.toml | 2 + crates/domains/ironclaw_web_push/README.md | 7 +- crates/domains/ironclaw_web_push/src/error.rs | 22 ++- .../domains/ironclaw_web_push/src/message.rs | 101 +++++++++++++- crates/domains/ironclaw_web_push/src/store.rs | 55 ++++++-- .../ironclaw_web_push/src/subscription.rs | 16 +++ crates/domains/ironclaw_web_push/src/vapid.rs | 39 ++++-- crates/extensions/AGENTS.md | 2 +- crates/extensions/packages/web-push/README.md | 9 +- .../packages/web-push/manifest.toml | 15 ++- .../packages/web-push/src/targets.rs | 5 +- .../ironclaw_host_runtime/src/egress/vapid.rs | 5 + crates/product/ironclaw_assistant/src/lib.rs | 10 +- .../src/reborn_services/web_push.rs | 3 +- .../ironclaw_webui/frontend/public/sw.js | 26 +++- .../ironclaw_webui/frontend/src/i18n/ar.ts | 5 +- .../ironclaw_webui/frontend/src/i18n/de.ts | 5 +- .../ironclaw_webui/frontend/src/i18n/en.ts | 5 +- .../ironclaw_webui/frontend/src/i18n/es.ts | 5 +- .../ironclaw_webui/frontend/src/i18n/fr.ts | 5 +- .../ironclaw_webui/frontend/src/i18n/hi.ts | 5 +- .../ironclaw_webui/frontend/src/i18n/ja.ts | 5 +- .../ironclaw_webui/frontend/src/i18n/ko.ts | 5 +- .../ironclaw_webui/frontend/src/i18n/pt-BR.ts | 5 +- .../ironclaw_webui/frontend/src/i18n/uk.ts | 5 +- .../ironclaw_webui/frontend/src/i18n/zh-CN.ts | 5 +- .../frontend/src/lib/web-push.test.ts | 84 +++++++++++- .../frontend/src/lib/web-push.ts | 115 +++++++++++++--- .../notification-channels-panel.test.ts | 126 +++++++++++++++++- .../notification-channels-panel.tsx | 45 +++++-- .../automations/hooks/useWebPushDevice.ts | 49 +++++-- .../ironclaw_webui/src/webui_v2/handlers.rs | 2 +- tests/integration/webui_v2_product_api.rs | 13 ++ 38 files changed, 880 insertions(+), 152 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f2310c801e1..c35049a63fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5160,8 +5160,10 @@ dependencies = [ "ironclaw_host_api", "serde", "serde_json", + "sha2 0.11.0", "thiserror 2.0.19", "tokio", + "tracing", "url", "uuid", ] diff --git a/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs b/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs index fba1140f737..49277ee7566 100644 --- a/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs +++ b/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs @@ -644,7 +644,11 @@ fn reborn_contracts_crates_carry_a_checked_size_ceiling() { // `VapidCredentialMaterialV1` material schema (RFC 8292 vocabulary, // declarations only); ES256 signing stays at the host egress // credential chokepoint in ironclaw_host_runtime. - ("ironclaw_host_api", 18_832), + // 18_832 -> 18_922 (web-push review): `VapidCredentialMaterialV1` gained + // a redacting `Debug`, a `validate_shape` fallible shape check, and a + // dependency-free base64url decode helper — all declaration/validation + // on the type's own shape, no execution. + ("ironclaw_host_api", 18_922), // 14_479 -> 13_949 (2026-08-07, #7157): downward re-capture after the // delivery-heuristic vocabulary (stored trigger delivery targets and // their run-profile plumbing) left this crate with the two-lane @@ -685,7 +689,10 @@ fn reborn_contracts_crates_carry_a_checked_size_ceiling() { // subscribe/unsubscribe command descriptors) joined per the // transport/product boundary — new feature descriptors are declared // here, not added to the frozen webui→assistant residue. - ("ironclaw_product_contracts", 15_879), + // 15_879 -> 15_885 (review): the `endpoint_digest` field + its doc on + // `RebornWebPushSubscriptionInfo` for account-scoped enrollment + // correlation. + ("ironclaw_product_contracts", 15_885), ("ironclaw_prompt_envelope", 832), ]; diff --git a/crates/app/ironclaw_composition/src/factory/production_backend_assembly.rs b/crates/app/ironclaw_composition/src/factory/production_backend_assembly.rs index 936c098636a..6f044dc37dd 100644 --- a/crates/app/ironclaw_composition/src/factory/production_backend_assembly.rs +++ b/crates/app/ironclaw_composition/src/factory/production_backend_assembly.rs @@ -1451,60 +1451,81 @@ where .map_err(|error| RebornBuildError::InvalidConfig { reason: format!("web push VAPID handle is invalid: {error}"), })?; + // Ensure the deployment VAPID keypair exists, then advertise the public + // key from the CANONICAL stored material — not from a locally-generated + // copy. The store has no create-if-absent primitive and permits + // replacement, so two replicas cold-starting against a shared empty store + // can each generate and `put` a distinct keypair (last write wins). By + // always reading back after ensuring presence, every replica converges on + // whichever keypair won the race and hands browsers the matching + // `applicationServerKey`; the only residual is a sub-second window on the + // losing replica between its own `put` and this read-back. A true + // single-writer election would need a create-if-absent secret-store + // primitive (follow-up). let existing = secret_store .metadata(channel_egress_scope, &vapid_handle) .await .map_err(|error| RebornBuildError::InvalidConfig { reason: format!("web push VAPID credential lookup failed: {error}"), })?; - let vapid_public_key = match existing { - Some(_) => { - // Re-derive the (non-secret) public half from the stored - // material so restarts advertise the same applicationServerKey. - let lease = secret_store - .lease_once(channel_egress_scope, &vapid_handle) - .await - .map_err(|error| RebornBuildError::InvalidConfig { - reason: format!("web push VAPID credential lease failed: {error}"), - })?; - let material = secret_store - .consume(channel_egress_scope, lease.id) - .await - .map_err(|error| RebornBuildError::InvalidConfig { - reason: format!("web push VAPID credential read failed: {error}"), - })?; - let parsed: ironclaw_host_api::http::VapidCredentialMaterialV1 = serde_json::from_str( - secrecy::ExposeSecret::expose_secret(&material), + if existing.is_none() { + let subject = vapid_subject + .map(str::to_string) + .unwrap_or_else(|| "mailto:webpush@ironclaw.invalid".to_string()); + let generated = + ironclaw_web_push::generate_vapid_key_material(&subject).map_err(|error| { + RebornBuildError::InvalidConfig { + reason: format!("web push VAPID key generation failed: {error}"), + } + })?; + secret_store + .put( + channel_egress_scope.clone(), + vapid_handle.clone(), + ironclaw_secrets::SecretMaterial::from(generated.material_json), + None, ) - .map_err(|_| RebornBuildError::InvalidConfig { - reason: "stored web push VAPID credential material is malformed".to_string(), + .await + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("web push VAPID credential seeding failed: {error}"), })?; - parsed.public_key_b64url - } - None => { - let subject = vapid_subject - .map(str::to_string) - .unwrap_or_else(|| "mailto:webpush@ironclaw.invalid".to_string()); - let generated = - ironclaw_web_push::generate_vapid_key_material(&subject).map_err(|error| { - RebornBuildError::InvalidConfig { - reason: format!("web push VAPID key generation failed: {error}"), - } - })?; - secret_store - .put( - channel_egress_scope.clone(), - vapid_handle.clone(), - ironclaw_secrets::SecretMaterial::from(generated.material_json), - None, - ) - .await - .map_err(|error| RebornBuildError::InvalidConfig { - reason: format!("web push VAPID credential seeding failed: {error}"), - })?; - generated.public_key_b64url - } - }; + } + // Read back the canonical material and validate its shape before exposing + // the public key — a corrupt persisted blob fails composition here rather + // than surfacing later as a push-service rejection on every delivery. + let lease = secret_store + .lease_once(channel_egress_scope, &vapid_handle) + .await + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("web push VAPID credential lease failed: {error}"), + })?; + let material = secret_store + .consume(channel_egress_scope, lease.id) + .await + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("web push VAPID credential read failed: {error}"), + })?; + let parsed: ironclaw_host_api::http::VapidCredentialMaterialV1 = + serde_json::from_str(secrecy::ExposeSecret::expose_secret(&material)).map_err(|error| { + // Log the bound serde cause server-side (it names the malformed + // field/offset) before mapping to a sanitized boot error — the + // material carries the private key, so the cause must not ride the + // returned error's message. + tracing::warn!( + target: "ironclaw::web_push", + error = %error, + "stored web push VAPID credential material failed to parse" + ); + RebornBuildError::InvalidConfig { + reason: "stored web push VAPID credential material is malformed".to_string(), + } + })?; + parsed + .validate_shape() + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("stored web push VAPID credential material is invalid: {error}"), + })?; + let vapid_public_key = parsed.public_key_b64url; Ok(Some(crate::factory::WebPushComposition { subscriptions, vapid_public_key, diff --git a/crates/contracts/ironclaw_host_api/src/http.rs b/crates/contracts/ironclaw_host_api/src/http.rs index 6bb514e33f5..d0be71255be 100644 --- a/crates/contracts/ironclaw_host_api/src/http.rs +++ b/crates/contracts/ironclaw_host_api/src/http.rs @@ -177,7 +177,12 @@ pub enum RuntimeCredentialTarget { /// are not, but travel inside the same material so the injector needs one /// resolution. Generation lives in `ironclaw_web_push`; parsing/signing at /// the host egress credential boundary. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// `Debug` is hand-written to redact the private key: this type is serialized +/// as a channel credential, and the safety boundary forbids raw secret +/// material in any debug output, log, event, or snapshot. A derived `Debug` +/// would render `es256_private_key_pkcs8_b64url` verbatim. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct VapidCredentialMaterialV1 { /// PKCS#8 P-256 private key, base64url (unpadded). @@ -189,6 +194,91 @@ pub struct VapidCredentialMaterialV1 { pub subject: String, } +impl std::fmt::Debug for VapidCredentialMaterialV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("VapidCredentialMaterialV1") + .field("es256_private_key_pkcs8_b64url", &"") + .field("public_key_b64url", &self.public_key_b64url) + .field("subject", &self.subject) + .finish() + } +} + +impl VapidCredentialMaterialV1 { + /// Validate the material's cryptographic shape: the private key must be a + /// parseable PKCS#8 P-256 signing key and the public key a 65-byte + /// uncompressed point whose base64url decodes cleanly. `deny_unknown_fields` + /// only rejects extra JSON keys; this rejects a structurally corrupt blob + /// so a bad persisted credential fails at composition rather than surfacing + /// later as a push-service rejection on every delivery. Kept dependency-free + /// (length/prefix + base64url checks only) so the contracts crate stays a + /// leaf; full keypair parsing happens at the signing boundary. + pub fn validate_shape(&self) -> Result<(), HostApiError> { + let public_key = decode_b64url_no_pad(&self.public_key_b64url).ok_or_else(|| { + HostApiError::invalid_runtime_credential_target( + "vapid_public_key", + "must be base64url (unpadded)", + ) + })?; + if public_key.len() != 65 || public_key.first() != Some(&0x04) { + return Err(HostApiError::invalid_runtime_credential_target( + "vapid_public_key", + "must decode to a 65-byte uncompressed P-256 point", + )); + } + if decode_b64url_no_pad(&self.es256_private_key_pkcs8_b64url).is_none() { + return Err(HostApiError::invalid_runtime_credential_target( + "vapid_private_key", + "must be base64url (unpadded)", + )); + } + let subject_ok = (self.subject.starts_with("mailto:") + && self.subject.len() > "mailto:".len()) + || (self.subject.starts_with("https://") && self.subject.len() > "https://".len()); + if !subject_ok || self.subject.len() > 256 || self.subject.chars().any(char::is_control) { + return Err(HostApiError::invalid_runtime_credential_target( + "vapid_subject", + "must be a short control-free mailto: or https: URI", + )); + } + Ok(()) + } +} + +/// Minimal unpadded-base64url decode, dependency-free so this stays in the +/// contracts leaf. Accepts the RFC 4648 URL-safe alphabet without padding. +fn decode_b64url_no_pad(value: &str) -> Option> { + fn sextet(byte: u8) -> Option { + match byte { + b'A'..=b'Z' => Some(byte - b'A'), + b'a'..=b'z' => Some(byte - b'a' + 26), + b'0'..=b'9' => Some(byte - b'0' + 52), + b'-' => Some(62), + b'_' => Some(63), + _ => None, + } + } + let bytes = value.as_bytes(); + if bytes.len() % 4 == 1 { + return None; + } + let mut out = Vec::with_capacity(bytes.len() / 4 * 3 + 2); + for chunk in bytes.chunks(4) { + let mut acc = 0u32; + for &byte in chunk { + acc = (acc << 6) | u32::from(sextet(byte)?); + } + let pad = 4 - chunk.len(); + acc <<= 6 * pad as u32; + let take = 3 - pad; + for index in 0..take { + out.push((acc >> (16 - 8 * index)) as u8); + } + } + Some(out) +} + pub fn valid_http_field_name(name: &str) -> bool { !name.is_empty() && name.bytes().all(|byte| { diff --git a/crates/contracts/ironclaw_product_contracts/src/product_wire.rs b/crates/contracts/ironclaw_product_contracts/src/product_wire.rs index 0ea8feff640..7f4159c839c 100644 --- a/crates/contracts/ironclaw_product_contracts/src/product_wire.rs +++ b/crates/contracts/ironclaw_product_contracts/src/product_wire.rs @@ -1001,11 +1001,17 @@ pub struct RebornWebPushUnsubscribeResponse { } /// One enrolled browser, redacted for the settings surface: the endpoint is -/// a bearer capability URL, so only its push-service host is projected. +/// a bearer capability URL, so only its push-service host is projected. The +/// `endpoint_digest` (lowercase-hex SHA-256 of the full endpoint) lets the +/// browser correlate its own local subscription with the caller's enrolled +/// set — distinguishing "enrolled for this account" from "enrolled for a +/// different account in this shared browser profile" — without the URL ever +/// leaving the backend. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RebornWebPushSubscriptionInfo { pub subscription_id: String, pub endpoint_host: String, + pub endpoint_digest: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub user_agent: Option, pub created_at: String, diff --git a/crates/domains/ironclaw_web_push/Cargo.toml b/crates/domains/ironclaw_web_push/Cargo.toml index 29d00afd882..a8344be72be 100644 --- a/crates/domains/ironclaw_web_push/Cargo.toml +++ b/crates/domains/ironclaw_web_push/Cargo.toml @@ -20,7 +20,9 @@ ironclaw_filesystem = { path = "../../substrates/ironclaw_filesystem" } ironclaw_host_api = { path = "../../contracts/ironclaw_host_api" } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.11" thiserror = "2" +tracing = "0.1" url = "2" uuid = { version = "1", features = ["v4", "serde"] } diff --git a/crates/domains/ironclaw_web_push/README.md b/crates/domains/ironclaw_web_push/README.md index c1df58c66a6..7ddf1f9ae8b 100644 --- a/crates/domains/ironclaw_web_push/README.md +++ b/crates/domains/ironclaw_web_push/README.md @@ -6,8 +6,11 @@ mechanics behind the web app's browser-notification channel. ## What it owns - `PushSubscriptionRecord` / `PushEndpoint` / `PushSubscriptionKeys` — the - validated per-browser enrollment grammar, including the exact-host push - service allowlist (`SUPPORTED_PUSH_SERVICE_HOSTS`). + validated per-browser enrollment grammar. The endpoint's push-service host + is checked at enrollment against the deployment-supplied allowlist + (`PushEndpoint::validate_against_push_services`), whose hosts composition + resolves from the web-push manifest `[[channel.egress]]` entries — one + source of truth with the egress path. - `WebPushSubscriptionStore` + the scoped-filesystem implementation — one CAS-updated JSON document per (tenant, user), capped at `MAX_SUBSCRIPTIONS_PER_USER` browsers. diff --git a/crates/domains/ironclaw_web_push/src/error.rs b/crates/domains/ironclaw_web_push/src/error.rs index f9bd8f31e70..53c1085cbf7 100644 --- a/crates/domains/ironclaw_web_push/src/error.rs +++ b/crates/domains/ironclaw_web_push/src/error.rs @@ -20,9 +20,12 @@ pub enum WebPushError { /// never material. #[error("web push crypto failure: {reason}")] Crypto { reason: String }, - /// The subscription store rejected or failed the operation. - #[error("web push subscription store failure: {reason}")] - Store { reason: String }, + /// The subscription store rejected or failed the operation. Carries a + /// fixed sanitized category only — backend diagnostics never enter the + /// message. The originating cause is logged server-side at the store + /// boundary (see [`WebPushError::store`]) before it is dropped. + #[error("web push subscription store failure")] + Store, /// The per-user subscription cap would be exceeded. #[error("subscription limit of {limit} browsers reached")] SubscriptionLimitReached { limit: usize }, @@ -38,10 +41,17 @@ pub enum WebPushError { } impl WebPushError { + /// Map a store/backend cause to the sanitized [`WebPushError::Store`] + /// category, logging the bound source server-side first so the diagnostic + /// is retained without ever entering the error's `Display` (the + /// redaction contract this module's header states). pub fn store(source: impl std::fmt::Display) -> Self { - Self::Store { - reason: source.to_string(), - } + tracing::debug!( + target: "ironclaw::web_push", + error = %source, + "web push subscription store operation failed" + ); + Self::Store } pub fn crypto(reason: impl Into) -> Self { diff --git a/crates/domains/ironclaw_web_push/src/message.rs b/crates/domains/ironclaw_web_push/src/message.rs index c863441d11a..29b44912572 100644 --- a/crates/domains/ironclaw_web_push/src/message.rs +++ b/crates/domains/ironclaw_web_push/src/message.rs @@ -24,6 +24,7 @@ pub const MAX_PAYLOAD_JSON_BYTES: usize = 3_800; const MAX_TITLE_CHARS: usize = 120; const MAX_BODY_CHARS: usize = 1_500; +const MAX_TAG_CHARS: usize = 64; const ELLIPSIS: char = '…'; /// RFC 8030 §5.3 urgency. @@ -60,21 +61,57 @@ pub struct WebPushNotificationPayload { } impl WebPushNotificationPayload { - /// Build a payload, truncating title/body to their display budgets. + /// Build a payload with every field forced into the notification grammar: + /// title/body sanitized and char-capped, `url` coerced to an app-relative + /// path (the service worker opens it), `tag` sanitized, and finally the + /// body trimmed by **serialized-JSON bytes** so a title made of multi-byte + /// characters can never blow the single-record push budget. Truncating by + /// character count alone let 1,500 four-byte emoji pass the char cap yet + /// serialize past 6 KiB, failing every send. pub fn new( title: impl Into, body: impl Into, url: impl Into, tag: Option, ) -> Self { - Self { + let mut payload = Self { title: truncate_chars(&sanitize_text(title.into()), MAX_TITLE_CHARS), body: truncate_chars(&sanitize_text(body.into()), MAX_BODY_CHARS), - url: url.into(), - tag, + url: app_relative_url(url.into()), + tag: tag.map(sanitize_tag), + }; + payload.fit_body_to_byte_budget(); + payload + } + + /// Trim `body` (char-boundary safe) until the serialized payload fits + /// `MAX_PAYLOAD_JSON_BYTES`. Bounded: each pass drops at least one + /// character, and only the body shrinks (title/url/tag are already capped). + fn fit_body_to_byte_budget(&mut self) { + while self.serialized_len() > MAX_PAYLOAD_JSON_BYTES { + let char_count = self.body.chars().count(); + if char_count == 0 { + break; + } + // Drop a proportional chunk to converge quickly on large payloads, + // always at least one character, then re-mark the truncation. + let overshoot = self.serialized_len() - MAX_PAYLOAD_JSON_BYTES; + let drop = (overshoot / 2).max(1).min(char_count); + let kept = char_count.saturating_sub(drop).saturating_sub(1); + let mut trimmed: String = self.body.chars().take(kept).collect(); + if kept < char_count { + trimmed.push(ELLIPSIS); + } + self.body = trimmed; } } + fn serialized_len(&self) -> usize { + serde_json::to_vec(self) + .map(|bytes| bytes.len()) + .unwrap_or(usize::MAX) + } + pub fn to_json_bytes(&self) -> Result, WebPushError> { let bytes = serde_json::to_vec(self) .map_err(|error| WebPushError::crypto(format!("payload serialization: {error}")))?; @@ -88,6 +125,26 @@ impl WebPushNotificationPayload { } } +/// Coerce a caller-supplied deep link into the app-relative form the service +/// worker's same-origin guard expects: a single leading `/`, no scheme, no +/// protocol-relative `//`, no control bytes. Anything else falls back to `/` +/// (the app root) rather than shipping a link the SW would reject anyway. +fn app_relative_url(raw: String) -> String { + let is_app_relative = raw.starts_with('/') + && !raw.starts_with("//") + && !raw.contains("://") + && !raw.chars().any(char::is_control); + if is_app_relative { + raw + } else { + "/".to_string() + } +} + +fn sanitize_tag(raw: String) -> String { + truncate_chars(&sanitize_text(raw), MAX_TAG_CHARS) +} + fn sanitize_text(raw: String) -> String { raw.chars() .map(|character| { @@ -187,6 +244,42 @@ mod tests { assert!(payload.body.ends_with(ELLIPSIS)); } + #[test] + fn multibyte_body_is_trimmed_to_the_serialized_byte_budget() { + // 1,500 four-byte emoji clear the character cap but serialize to ~6 KB + // of escaped JSON; char-only truncation would let this blow the push + // budget and fail every send. The byte-aware fit must bring it under. + let payload = WebPushNotificationPayload::new( + "IronClaw", + "🍉".repeat(MAX_BODY_CHARS), + "/automations", + None, + ); + let serialized = payload + .to_json_bytes() + .expect("multibyte payload fits the budget"); + assert!( + serialized.len() <= MAX_PAYLOAD_JSON_BYTES, + "serialized {} bytes exceeds the {MAX_PAYLOAD_JSON_BYTES}-byte budget", + serialized.len() + ); + } + + #[test] + fn non_app_relative_urls_collapse_to_root() { + for url in [ + "https://evil.example.com/x", + "//evil.example.com", + "javascript:alert(1)", + "/ok\nnewline", + ] { + let payload = WebPushNotificationPayload::new("t", "b", url, None); + assert_eq!(payload.url, "/", "{url:?} must collapse to root"); + } + let ok = WebPushNotificationPayload::new("t", "b", "/automations?x=1", None); + assert_eq!(ok.url, "/automations?x=1"); + } + #[test] fn plan_carries_protocol_headers_and_origin_form_path() { let subscription = sample_subscription(); diff --git a/crates/domains/ironclaw_web_push/src/store.rs b/crates/domains/ironclaw_web_push/src/store.rs index 572bbf0bea6..bf106a8b105 100644 --- a/crates/domains/ironclaw_web_push/src/store.rs +++ b/crates/domains/ironclaw_web_push/src/store.rs @@ -107,20 +107,19 @@ where } fn document_path() -> Result { - ScopedPath::new(SUBSCRIPTIONS_DOCUMENT).map_err(|error| WebPushError::Store { - reason: format!("subscription path rejected: {error}"), - }) + ScopedPath::new(SUBSCRIPTIONS_DOCUMENT) + .map_err(|error| WebPushError::store(format!("subscription path rejected: {error}"))) } fn decode_document(bytes: &[u8]) -> Result { - serde_json::from_slice(bytes).map_err(|error| WebPushError::Store { - reason: format!("subscription document decode failed: {error}"), + serde_json::from_slice(bytes).map_err(|error| { + WebPushError::store(format!("subscription document decode failed: {error}")) }) } fn encode_document(document: &SubscriptionDocument) -> Result { - let bytes = serde_json::to_vec(document).map_err(|error| WebPushError::Store { - reason: format!("subscription document encode failed: {error}"), + let bytes = serde_json::to_vec(document).map_err(|error| { + WebPushError::store(format!("subscription document encode failed: {error}")) })?; Ok(Entry::bytes(bytes).with_content_type(ContentType::json())) } @@ -128,9 +127,7 @@ fn encode_document(document: &SubscriptionDocument) -> Result) -> WebPushError { match error { CasUpdateError::Apply(inner) => inner, - other => WebPushError::Store { - reason: format!("subscription document update failed: {other}"), - }, + other => WebPushError::store(format!("subscription document update failed: {other}")), } } @@ -393,4 +390,42 @@ mod tests { Err(WebPushError::SubscriptionLimitReached { .. }) )); } + + /// Two browsers enrolling at once against the same (empty) document must + /// BOTH land: `cas_update` re-reads and re-applies on conflict, so the + /// loser of the first commit retries against the winner's document rather + /// than clobbering it. `.claude/rules/database.md` requires conflict/retry + /// behavior to be proven at the public domain-operation seam for new + /// persistence — drive it through `upsert_subscription`, not `cas_update`. + #[tokio::test] + async fn concurrent_enrollments_from_two_browsers_both_persist() { + let store = Arc::new(store()); + let scope = scope("user1"); + + let first = { + let store = Arc::clone(&store); + let scope = scope.clone(); + tokio::spawn(async move { store.upsert_subscription(&scope, record("alpha", 1)).await }) + }; + let second = { + let store = Arc::clone(&store); + let scope = scope.clone(); + tokio::spawn(async move { store.upsert_subscription(&scope, record("beta", 2)).await }) + }; + first.await.expect("join alpha").expect("enroll alpha"); + second.await.expect("join beta").expect("enroll beta"); + + let listed = store.list_subscriptions(&scope).await.expect("list"); + assert_eq!( + listed.len(), + 2, + "a lost enrollment means the CAS retry clobbered instead of re-applying" + ); + let endpoints: std::collections::BTreeSet<&str> = listed + .iter() + .map(|record| record.endpoint.as_str()) + .collect(); + assert!(endpoints.iter().any(|endpoint| endpoint.ends_with("alpha"))); + assert!(endpoints.iter().any(|endpoint| endpoint.ends_with("beta"))); + } } diff --git a/crates/domains/ironclaw_web_push/src/subscription.rs b/crates/domains/ironclaw_web_push/src/subscription.rs index ea2ea09e4be..69272d3c3e7 100644 --- a/crates/domains/ironclaw_web_push/src/subscription.rs +++ b/crates/domains/ironclaw_web_push/src/subscription.rs @@ -112,6 +112,22 @@ impl PushEndpoint { }) } + /// Lowercase-hex SHA-256 of the full endpoint URL. The endpoint itself is + /// a bearer capability the settings surface must not echo, but the browser + /// can compute the same digest over its local subscription endpoint and + /// match it against the caller's enrolled set — so a shared browser profile + /// tells "enrolled for this account" apart from "enrolled for another" + /// without the backend ever surfacing the URL. + pub fn digest(&self) -> String { + use sha2::{Digest, Sha256}; + let hash = Sha256::digest(self.0.as_bytes()); + let mut hex = String::with_capacity(hash.len() * 2); + for byte in hash { + hex.push_str(&format!("{byte:02x}")); + } + hex + } + /// Origin-form path + query for the restricted egress request. pub fn path_and_query(&self) -> Result { let parsed = url::Url::parse(&self.0).map_err(|_| WebPushError::InvalidSubscription { diff --git a/crates/domains/ironclaw_web_push/src/vapid.rs b/crates/domains/ironclaw_web_push/src/vapid.rs index b455dbf891a..4392bafc7de 100644 --- a/crates/domains/ironclaw_web_push/src/vapid.rs +++ b/crates/domains/ironclaw_web_push/src/vapid.rs @@ -18,7 +18,12 @@ use crate::error::WebPushError; /// Freshly generated VAPID material: the JSON blob to store as the channel /// credential, plus the (non-secret) public key the browser needs as /// `applicationServerKey`. -#[derive(Debug, Clone)] +/// +/// `Debug` is hand-written to redact `material_json` — it is the serialized +/// [`VapidCredentialMaterialV1`] and carries the ES256 private key, which the +/// safety boundary forbids from reaching any debug output, log, or panic +/// message. A derived `Debug` would print the key verbatim. +#[derive(Clone)] pub struct GeneratedVapidKeyMaterial { /// Serialized [`VapidCredentialMaterialV1`] — store as the channel /// credential under the web-push VAPID handle. Contains the private key. @@ -27,6 +32,16 @@ pub struct GeneratedVapidKeyMaterial { pub public_key_b64url: String, } +impl std::fmt::Debug for GeneratedVapidKeyMaterial { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("GeneratedVapidKeyMaterial") + .field("material_json", &"") + .field("public_key_b64url", &self.public_key_b64url) + .finish() + } +} + /// Generate a fresh P-256 keypair and wrap it in the credential-material /// schema the host egress VAPID injector consumes. /// @@ -66,18 +81,26 @@ pub fn generate_vapid_key_material( } /// RFC 8292 §2.1: the subject is a contact URI for the application server — -/// `mailto:` or `https:`. +/// `mailto:` or `https:`. Parsed with `url` (a crate dependency already) so a +/// malformed URI is rejected at generation rather than surfacing later as a +/// push-service rejection on every delivery. pub fn validate_vapid_subject(subject: &str) -> Result<(), WebPushError> { - let valid = subject.starts_with("mailto:") && subject.len() > "mailto:".len() - || subject.starts_with("https://") && subject.len() > "https://".len(); - if !valid { + if subject.len() > 256 || subject.chars().any(char::is_control) { return Err(WebPushError::InvalidScope { - reason: "VAPID subject must be a mailto: or https: URI".to_string(), + reason: "VAPID subject must be a short control-free URI".to_string(), }); } - if subject.len() > 256 || subject.chars().any(char::is_control) { + let parsed = url::Url::parse(subject).map_err(|_| WebPushError::InvalidScope { + reason: "VAPID subject must be a valid mailto: or https: URI".to_string(), + })?; + let valid = match parsed.scheme() { + "mailto" => !parsed.path().is_empty(), + "https" => parsed.host_str().is_some(), + _ => false, + }; + if !valid { return Err(WebPushError::InvalidScope { - reason: "VAPID subject must be a short control-free URI".to_string(), + reason: "VAPID subject must be a mailto: address or https: URL".to_string(), }); } Ok(()) diff --git a/crates/extensions/AGENTS.md b/crates/extensions/AGENTS.md index 75c1668ef29..52bcbfb9d7d 100644 --- a/crates/extensions/AGENTS.md +++ b/crates/extensions/AGENTS.md @@ -1,6 +1,6 @@ # `crates/extensions/` — everything "installable package" -**Layer(s):** substrates (`ironclaw_extension_registry`, both memory providers) · runtimes (`ironclaw_extension_support`) · loops (`ironclaw_extension_host`) · products (`ironclaw_extension_manager`, both channel packages) · **Crates:** 9 (re-derive: `ls -d crates/extensions/*/` for the family crates, `ls -d crates/extensions/packages/*/` for the 14 packages, of which 4 carry crates) · **May depend on:** downward only, per crate — the registry reaches contracts + `ironclaw_filesystem`; the host reaches kernel, domains, and loop; packages reach contracts (+ the domain contract a provider implements) · **Depended on by:** the binary (`ironclaw_cli`) and `ironclaw_composition`; the registry additionally by kernel/lanes/loop/events crates that read manifest vocabulary; `ironclaw_webui` holds one sanctioned edge onto the host (pairing). +**Layer(s):** substrates (`ironclaw_extension_registry`, both memory providers) · runtimes (`ironclaw_extension_support`) · loops (`ironclaw_extension_host`) · products (`ironclaw_extension_manager`, the channel packages) · **Crates:** 10 (re-derive: `ls -d crates/extensions/*/` for the family crates, `ls -d crates/extensions/packages/*/` for the 15 packages, of which 5 carry crates) · **May depend on:** downward only, per crate — the registry reaches contracts + `ironclaw_filesystem`; the host reaches kernel, domains, and loop; packages reach contracts (+ the domain contract a provider implements) · **Depended on by:** the binary (`ironclaw_cli`) and `ironclaw_composition`; the registry additionally by kernel/lanes/loop/events crates that read manifest vocabulary; `ironclaw_webui` holds one sanctioned edge onto the host (pairing). ## What this family is diff --git a/crates/extensions/packages/web-push/README.md b/crates/extensions/packages/web-push/README.md index e2564fe1375..38a279c0ce8 100644 --- a/crates/extensions/packages/web-push/README.md +++ b/crates/extensions/packages/web-push/README.md @@ -21,9 +21,12 @@ The web app's browser-notification channel: outbound-only Web Push readable message reference, so delivery reports `Sent` with no vendor ref — acceptance by the push service, not device receipt. -The egress host allowlist in `manifest.toml` must stay in lockstep with -`ironclaw_web_push::SUPPORTED_PUSH_SERVICE_HOSTS` -(`tests/manifest_lockstep.rs` pins it). +The `[[channel.egress]]` hosts in `manifest.toml` are the deployment's +push-service allowlist: composition reads them back at boot and hands them to +enrollment validation (`PushEndpoint::validate_against_push_services`), so the +same list bounds both which endpoints may enroll and where deliveries may go. +`tests/manifest_lockstep.rs` pins the egress declarations' shape (https-only, +VAPID credential, `vapid_authorization` injection). ## Validation diff --git a/crates/extensions/packages/web-push/manifest.toml b/crates/extensions/packages/web-push/manifest.toml index 1bb90ebdb9a..0638f61cc71 100644 --- a/crates/extensions/packages/web-push/manifest.toml +++ b/crates/extensions/packages/web-push/manifest.toml @@ -14,13 +14,22 @@ trust = "first_party_requested" kind = "first_party" service = "web-push.extension/v1" +# The VAPID key material handle. A channel egress `credential_handle` MUST be +# declared here (manifest invariant), so the field is present — but it is +# `required = false` and NOT operator-supplied: composition generates the +# keypair at boot and seeds it into the scoped secret store, which is where the +# `vapid_authorization` egress injection and `/web-push/status` both read it +# from (one canonical value, so the advertised `applicationServerKey` matches +# the signing key). Do not hand-edit it: overwriting the material through +# operator config rotates the signing key, and until a coherent +# regenerate-and-republish rotation flow lands, enrollments made against the +# previous `applicationServerKey` would need to re-enroll after the change +# takes effect on restart. [admin_configuration] group_id = "extension.web-push" display_name = "Web push deployment configuration" -description = "Auto-generated VAPID application-server key material for browser push." +description = "Auto-generated VAPID application-server key material for browser push (host-managed; not operator-supplied)." fields = [ - # Seeded programmatically at boot when absent; `required = false` so the - # operator config surface never demands manual input. { handle = "web_push_vapid", label = "VAPID key material (auto-generated)", secret = true, required = false }, ] diff --git a/crates/extensions/packages/web-push/src/targets.rs b/crates/extensions/packages/web-push/src/targets.rs index 33059f3ca31..a245322636d 100644 --- a/crates/extensions/packages/web-push/src/targets.rs +++ b/crates/extensions/packages/web-push/src/targets.rs @@ -83,8 +83,9 @@ impl OutboundDeliveryTargetProvider for WebPushOutboundTargetProvider { } } -/// Registry key composition registers the provider under. -pub const WEB_PUSH_TARGET_PROVIDER_KEY: &str = "web-push"; +/// Registry key composition registers the provider under — the extension id, +/// so the provider key and the manifest identity cannot drift. +pub const WEB_PUSH_TARGET_PROVIDER_KEY: &str = ironclaw_web_push::WEB_PUSH_EXTENSION_ID; #[cfg(test)] mod tests { diff --git a/crates/kernel/ironclaw_host_runtime/src/egress/vapid.rs b/crates/kernel/ironclaw_host_runtime/src/egress/vapid.rs index 66063447b25..954532595f5 100644 --- a/crates/kernel/ironclaw_host_runtime/src/egress/vapid.rs +++ b/crates/kernel/ironclaw_host_runtime/src/egress/vapid.rs @@ -45,6 +45,11 @@ pub(super) fn vapid_authorization_header( ) -> Result { let material: VapidCredentialMaterialV1 = serde_json::from_str(material_json).map_err(|_| VapidHeaderError::MaterialInvalid)?; + // Reject a structurally corrupt blob before it can produce a token no push + // service will accept (base64url shape, P-256 point, subject grammar). + material + .validate_shape() + .map_err(|_| VapidHeaderError::MaterialInvalid)?; // Push service audiences are https origins; the outer injection gate has // already required TLS (or literal loopback, which never hosts a push diff --git a/crates/product/ironclaw_assistant/src/lib.rs b/crates/product/ironclaw_assistant/src/lib.rs index ad7ffb29ddb..3c6d45eadcd 100644 --- a/crates/product/ironclaw_assistant/src/lib.rs +++ b/crates/product/ironclaw_assistant/src/lib.rs @@ -371,11 +371,11 @@ pub use reborn_services::{ UnsupportedAutomationProductService, UnsupportedOperatorLogsService, UnsupportedOperatorServiceLifecycleService, UnsupportedOperatorStatusService, UnsupportedOutboundPreferencesProductService, UnsupportedWebPushProductService, - WebPushProductService, - list_outbound_delivery_targets_for_model, notification_channels_set_input_schema, - notification_channels_set_operator_tool_info, outbound_delivery_synthetic_provider, - outbound_delivery_targets_list_input_schema, parse_notification_channels_set_input, - parse_outbound_delivery_targets_list_input, set_notification_channels_for_model, + WebPushProductService, list_outbound_delivery_targets_for_model, + notification_channels_set_input_schema, notification_channels_set_operator_tool_info, + outbound_delivery_synthetic_provider, outbound_delivery_targets_list_input_schema, + parse_notification_channels_set_input, parse_outbound_delivery_targets_list_input, + set_notification_channels_for_model, }; pub use product_surface_inbound::{ diff --git a/crates/product/ironclaw_assistant/src/reborn_services/web_push.rs b/crates/product/ironclaw_assistant/src/reborn_services/web_push.rs index b1df0c566f4..32da989616a 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/web_push.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/web_push.rs @@ -131,6 +131,7 @@ impl WebPushProductService for RebornWebPushProductService { Ok(RebornWebPushSubscriptionInfo { subscription_id: record.subscription_id.clone(), endpoint_host: record.endpoint.host().map_err(map_web_push_error)?, + endpoint_digest: record.endpoint.digest(), user_agent: record.user_agent.clone(), created_at: record.created_at.clone(), }) @@ -216,7 +217,7 @@ fn map_web_push_error(error: WebPushError) -> ProductSurfaceError { tracing::error!(%error, "web push scope projection failed"); ProductSurfaceError::internal_from(error) } - WebPushError::Store { .. } => { + WebPushError::Store => { tracing::warn!(%error, "web push subscription store failure"); ProductSurfaceError::service_unavailable(true) } diff --git a/crates/product/ironclaw_webui/frontend/public/sw.js b/crates/product/ironclaw_webui/frontend/public/sw.js index 8da5ef6b028..bba8e6248b6 100644 --- a/crates/product/ironclaw_webui/frontend/public/sw.js +++ b/crates/product/ironclaw_webui/frontend/public/sw.js @@ -15,6 +15,21 @@ self.addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()); }); +// Deep links are same-origin paths by contract. Payloads are produced by our +// own backend, but the notification store outlives deploys and a push +// payload is still external input to this worker — so the contract is +// enforced here, where navigation happens: anything that does not resolve to +// this origin collapses to "/". +function sameOriginPath(value) { + try { + const target = new URL(value, self.location.origin); + if (target.origin !== self.location.origin) return "/"; + return `${target.pathname}${target.search}${target.hash}`; + } catch (_) { + return "/"; + } +} + function payloadFromEvent(event) { const fallback = { title: "IronClaw", @@ -28,7 +43,10 @@ function payloadFromEvent(event) { return { title: typeof parsed.title === "string" && parsed.title ? parsed.title : fallback.title, body: typeof parsed.body === "string" ? parsed.body : fallback.body, - url: typeof parsed.url === "string" && parsed.url ? parsed.url : fallback.url, + url: + typeof parsed.url === "string" && parsed.url + ? sameOriginPath(parsed.url) + : fallback.url, tag: typeof parsed.tag === "string" && parsed.tag ? parsed.tag : undefined, }; } catch (_) { @@ -51,7 +69,11 @@ self.addEventListener("push", (event) => { self.addEventListener("notificationclick", (event) => { event.notification.close(); - const url = (event.notification.data && event.notification.data.url) || "/"; + // Re-validate here as well: stored notifications can predate this worker's + // version, so `data.url` is not trusted to already be origin-checked. + const url = sameOriginPath( + (event.notification.data && event.notification.data.url) || "/", + ); event.waitUntil( self.clients .matchAll({ type: "window", includeUncontrolled: true }) diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts index 249adcb75bf..c9cc61f3e9b 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts @@ -797,7 +797,7 @@ registerPack("ar", { "automations.notificationChannels.webOnlyHelper": "لم يتم اختيار أي قناة إشعارات — لن يتم تسليم طلبات الموافقة وتنبيهات المصادقة وإشعارات الفشل إلى أي وجهة.", "automations.notificationChannels.webPush.deviceHeading": "هذا المتصفح", "automations.notificationChannels.webPush.checking": "جارٍ التحقق من دعم الإشعارات في هذا المتصفح…", - "automations.notificationChannels.webPush.unsupported": "هذا المتصفح لا يدعم الإشعارات الفورية.", + "automations.notificationChannels.webPush.unsupported": "إشعارات الدفع غير متاحة في هذا المتصفح.", "automations.notificationChannels.webPush.permissionDenied": "الإشعارات محظورة لهذا الموقع. اسمح بها في إعدادات الموقع في متصفحك ثم حاول مرة أخرى.", "automations.notificationChannels.webPush.notEnrolled": "هذا المتصفح لا يتلقى الإشعارات بعد.", "automations.notificationChannels.webPush.enrolled": "هذا المتصفح يتلقى الإشعارات.", @@ -805,6 +805,9 @@ registerPack("ar", { "automations.notificationChannels.webPush.unenroll": "التعطيل في هذا المتصفح", "automations.notificationChannels.webPush.deviceCount": "المتصفحات المسجلة: {count}", "automations.notificationChannels.webPush.actionFailed": "تعذر تحديث تسجيل الإشعارات لهذا المتصفح. يرجى المحاولة مرة أخرى.", + "automations.notificationChannels.webPush.enrolledOtherAccount": "هذا المتصفح مسجَّل لحساب آخر. يمكنك تفعيله لهذا الحساب أيضًا.", + "automations.notificationChannels.webPush.enableForAccount": "تفعيل لهذا الحساب", + "automations.notificationChannels.webPush.statusFailed": "تعذّر تحميل حالة تسجيل هذا الحساب. أعد تحميل الصفحة.", "automations.notificationChannels.save": "حفظ", "automations.notificationChannels.saved": "تم الحفظ", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/de.ts b/crates/product/ironclaw_webui/frontend/src/i18n/de.ts index 98d63503cd7..dcabe8c1811 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/de.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/de.ts @@ -797,7 +797,7 @@ registerPack("de", { "automations.notificationChannels.webOnlyHelper": "Kein Benachrichtigungskanal ausgewählt — Freigabe-Anfragen, Anmelde-Aufforderungen und Fehlermeldungen werden nirgendwohin zugestellt.", "automations.notificationChannels.webPush.deviceHeading": "Dieser Browser", "automations.notificationChannels.webPush.checking": "Benachrichtigungsunterstützung dieses Browsers wird geprüft…", - "automations.notificationChannels.webPush.unsupported": "Dieser Browser unterstützt keine Push-Benachrichtigungen.", + "automations.notificationChannels.webPush.unsupported": "Push-Benachrichtigungen sind in diesem Browser nicht verfügbar.", "automations.notificationChannels.webPush.permissionDenied": "Benachrichtigungen sind für diese Website blockiert. Erlaube sie in den Website-Einstellungen deines Browsers und versuche es erneut.", "automations.notificationChannels.webPush.notEnrolled": "Dieser Browser empfängt noch keine Benachrichtigungen.", "automations.notificationChannels.webPush.enrolled": "Dieser Browser empfängt Benachrichtigungen.", @@ -805,6 +805,9 @@ registerPack("de", { "automations.notificationChannels.webPush.unenroll": "In diesem Browser deaktivieren", "automations.notificationChannels.webPush.deviceCount": "Registrierte Browser: {count}", "automations.notificationChannels.webPush.actionFailed": "Die Benachrichtigungsregistrierung dieses Browsers konnte nicht geändert werden. Bitte versuche es erneut.", + "automations.notificationChannels.webPush.enrolledOtherAccount": "Dieser Browser ist für ein anderes Konto registriert. Du kannst ihn auch für dieses Konto aktivieren.", + "automations.notificationChannels.webPush.enableForAccount": "Für dieses Konto aktivieren", + "automations.notificationChannels.webPush.statusFailed": "Der Registrierungsstatus dieses Kontos konnte nicht geladen werden. Lade die Seite neu.", "automations.notificationChannels.save": "Speichern", "automations.notificationChannels.saved": "Gespeichert", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/en.ts b/crates/product/ironclaw_webui/frontend/src/i18n/en.ts index 120501b533c..fd9973b4912 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/en.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/en.ts @@ -846,7 +846,7 @@ registerPack("en", { "automations.notificationChannels.webOnlyHelper": "No notification channel is selected — approval prompts, auth prompts, and failure notices won't be delivered anywhere.", "automations.notificationChannels.webPush.deviceHeading": "This browser", "automations.notificationChannels.webPush.checking": "Checking this browser's notification support…", - "automations.notificationChannels.webPush.unsupported": "This browser doesn't support push notifications.", + "automations.notificationChannels.webPush.unsupported": "Push notifications aren't available in this browser.", "automations.notificationChannels.webPush.permissionDenied": "Notifications are blocked for this site. Allow them in your browser's site settings, then try again.", "automations.notificationChannels.webPush.notEnrolled": "This browser isn't receiving notifications yet.", "automations.notificationChannels.webPush.enrolled": "This browser receives notifications.", @@ -854,6 +854,9 @@ registerPack("en", { "automations.notificationChannels.webPush.unenroll": "Disable in this browser", "automations.notificationChannels.webPush.deviceCount": "Enrolled browsers: {count}", "automations.notificationChannels.webPush.actionFailed": "Couldn't update this browser's notification enrollment. Please try again.", + "automations.notificationChannels.webPush.enrolledOtherAccount": "This browser is enrolled for a different account. You can enable it for this account too.", + "automations.notificationChannels.webPush.enableForAccount": "Enable for this account", + "automations.notificationChannels.webPush.statusFailed": "Couldn't load this account's enrollment status. Try reloading the page.", "automations.notificationChannels.save": "Save", "automations.notificationChannels.saved": "Saved", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/es.ts b/crates/product/ironclaw_webui/frontend/src/i18n/es.ts index ffa7f7a262c..6ae0f17528a 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/es.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/es.ts @@ -797,7 +797,7 @@ registerPack("es", { "automations.notificationChannels.webOnlyHelper": "No hay ningún canal de notificaciones seleccionado: las solicitudes de aprobación, los avisos de autenticación y los fallos no se entregarán a ningún destino.", "automations.notificationChannels.webPush.deviceHeading": "Este navegador", "automations.notificationChannels.webPush.checking": "Comprobando la compatibilidad de notificaciones de este navegador…", - "automations.notificationChannels.webPush.unsupported": "Este navegador no admite notificaciones push.", + "automations.notificationChannels.webPush.unsupported": "Las notificaciones push no están disponibles en este navegador.", "automations.notificationChannels.webPush.permissionDenied": "Las notificaciones están bloqueadas para este sitio. Permítelas en la configuración del sitio de tu navegador e inténtalo de nuevo.", "automations.notificationChannels.webPush.notEnrolled": "Este navegador aún no recibe notificaciones.", "automations.notificationChannels.webPush.enrolled": "Este navegador recibe notificaciones.", @@ -805,6 +805,9 @@ registerPack("es", { "automations.notificationChannels.webPush.unenroll": "Desactivar en este navegador", "automations.notificationChannels.webPush.deviceCount": "Navegadores registrados: {count}", "automations.notificationChannels.webPush.actionFailed": "No se pudo actualizar el registro de notificaciones de este navegador. Inténtalo de nuevo.", + "automations.notificationChannels.webPush.enrolledOtherAccount": "Este navegador está registrado para otra cuenta. También puedes activarlo para esta cuenta.", + "automations.notificationChannels.webPush.enableForAccount": "Activar para esta cuenta", + "automations.notificationChannels.webPush.statusFailed": "No se pudo cargar el estado de registro de esta cuenta. Recarga la página.", "automations.notificationChannels.save": "Guardar", "automations.notificationChannels.saved": "Guardado", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts b/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts index c4a889e00d0..9504b24bd76 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts @@ -797,7 +797,7 @@ registerPack("fr", { "automations.notificationChannels.webOnlyHelper": "Aucun canal de notification n'est sélectionné — les demandes d'approbation, les invites d'authentification et les avis d'échec ne seront livrés nulle part.", "automations.notificationChannels.webPush.deviceHeading": "Ce navigateur", "automations.notificationChannels.webPush.checking": "Vérification de la prise en charge des notifications par ce navigateur…", - "automations.notificationChannels.webPush.unsupported": "Ce navigateur ne prend pas en charge les notifications push.", + "automations.notificationChannels.webPush.unsupported": "Les notifications push ne sont pas disponibles dans ce navigateur.", "automations.notificationChannels.webPush.permissionDenied": "Les notifications sont bloquées pour ce site. Autorisez-les dans les paramètres du site de votre navigateur, puis réessayez.", "automations.notificationChannels.webPush.notEnrolled": "Ce navigateur ne reçoit pas encore de notifications.", "automations.notificationChannels.webPush.enrolled": "Ce navigateur reçoit les notifications.", @@ -805,6 +805,9 @@ registerPack("fr", { "automations.notificationChannels.webPush.unenroll": "Désactiver dans ce navigateur", "automations.notificationChannels.webPush.deviceCount": "Navigateurs inscrits : {count}", "automations.notificationChannels.webPush.actionFailed": "Impossible de mettre à jour l'inscription aux notifications de ce navigateur. Veuillez réessayer.", + "automations.notificationChannels.webPush.enrolledOtherAccount": "Ce navigateur est inscrit pour un autre compte. Vous pouvez aussi l'activer pour ce compte.", + "automations.notificationChannels.webPush.enableForAccount": "Activer pour ce compte", + "automations.notificationChannels.webPush.statusFailed": "Impossible de charger l'état d'inscription de ce compte. Rechargez la page.", "automations.notificationChannels.save": "Enregistrer", "automations.notificationChannels.saved": "Enregistré", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts b/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts index 14d93e9644e..19820af3d31 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts @@ -797,7 +797,7 @@ registerPack("hi", { "automations.notificationChannels.webOnlyHelper": "कोई सूचना चैनल चयनित नहीं है — अनुमोदन अनुरोध, प्रमाणीकरण संकेत और विफलता सूचनाएँ कहीं नहीं पहुँचाई जाएँगी।", "automations.notificationChannels.webPush.deviceHeading": "यह ब्राउज़र", "automations.notificationChannels.webPush.checking": "इस ब्राउज़र में सूचना समर्थन की जाँच हो रही है…", - "automations.notificationChannels.webPush.unsupported": "यह ब्राउज़र पुश सूचनाओं का समर्थन नहीं करता।", + "automations.notificationChannels.webPush.unsupported": "इस ब्राउज़र में पुश सूचनाएँ उपलब्ध नहीं हैं।", "automations.notificationChannels.webPush.permissionDenied": "इस साइट के लिए सूचनाएँ अवरुद्ध हैं। अपने ब्राउज़र की साइट सेटिंग में उन्हें अनुमति दें, फिर पुनः प्रयास करें।", "automations.notificationChannels.webPush.notEnrolled": "यह ब्राउज़र अभी सूचनाएँ प्राप्त नहीं कर रहा है।", "automations.notificationChannels.webPush.enrolled": "यह ब्राउज़र सूचनाएँ प्राप्त करता है।", @@ -805,6 +805,9 @@ registerPack("hi", { "automations.notificationChannels.webPush.unenroll": "इस ब्राउज़र में अक्षम करें", "automations.notificationChannels.webPush.deviceCount": "पंजीकृत ब्राउज़र: {count}", "automations.notificationChannels.webPush.actionFailed": "इस ब्राउज़र का सूचना पंजीकरण अपडेट नहीं हो सका। कृपया पुनः प्रयास करें।", + "automations.notificationChannels.webPush.enrolledOtherAccount": "यह ब्राउज़र किसी दूसरे खाते के लिए नामांकित है। आप इसे इस खाते के लिए भी सक्षम कर सकते हैं।", + "automations.notificationChannels.webPush.enableForAccount": "इस खाते के लिए सक्षम करें", + "automations.notificationChannels.webPush.statusFailed": "इस खाते की नामांकन स्थिति लोड नहीं हो सकी। पृष्ठ को फिर से लोड करें।", "automations.notificationChannels.save": "सहेजें", "automations.notificationChannels.saved": "सहेजा गया", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts index d51c6eb4671..279a2477054 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts @@ -797,7 +797,7 @@ registerPack("ja", { "automations.notificationChannels.webOnlyHelper": "通知チャネルが選択されていません — 承認リクエスト、認証プロンプト、失敗通知はどこにも配信されません。", "automations.notificationChannels.webPush.deviceHeading": "このブラウザ", "automations.notificationChannels.webPush.checking": "このブラウザの通知サポートを確認しています…", - "automations.notificationChannels.webPush.unsupported": "このブラウザはプッシュ通知に対応していません。", + "automations.notificationChannels.webPush.unsupported": "このブラウザではプッシュ通知を利用できません。", "automations.notificationChannels.webPush.permissionDenied": "このサイトの通知がブロックされています。ブラウザのサイト設定で通知を許可してから、もう一度お試しください。", "automations.notificationChannels.webPush.notEnrolled": "このブラウザはまだ通知を受信していません。", "automations.notificationChannels.webPush.enrolled": "このブラウザは通知を受信します。", @@ -805,6 +805,9 @@ registerPack("ja", { "automations.notificationChannels.webPush.unenroll": "このブラウザで無効にする", "automations.notificationChannels.webPush.deviceCount": "登録済みブラウザ: {count}", "automations.notificationChannels.webPush.actionFailed": "このブラウザの通知登録を更新できませんでした。もう一度お試しください。", + "automations.notificationChannels.webPush.enrolledOtherAccount": "このブラウザは別のアカウントで登録されています。このアカウントでも有効にできます。", + "automations.notificationChannels.webPush.enableForAccount": "このアカウントで有効にする", + "automations.notificationChannels.webPush.statusFailed": "このアカウントの登録状態を読み込めませんでした。ページを再読み込みしてください。", "automations.notificationChannels.save": "保存", "automations.notificationChannels.saved": "保存しました", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts index 8ba19b30676..1ad10dc338b 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts @@ -797,7 +797,7 @@ registerPack("ko", { "automations.notificationChannels.webOnlyHelper": "선택된 알림 채널이 없습니다 — 승인 요청, 인증 안내, 실패 알림이 어디에도 전달되지 않습니다.", "automations.notificationChannels.webPush.deviceHeading": "이 브라우저", "automations.notificationChannels.webPush.checking": "이 브라우저의 알림 지원을 확인하는 중…", - "automations.notificationChannels.webPush.unsupported": "이 브라우저는 푸시 알림을 지원하지 않습니다.", + "automations.notificationChannels.webPush.unsupported": "이 브라우저에서는 푸시 알림을 사용할 수 없습니다.", "automations.notificationChannels.webPush.permissionDenied": "이 사이트의 알림이 차단되어 있습니다. 브라우저의 사이트 설정에서 알림을 허용한 후 다시 시도하세요.", "automations.notificationChannels.webPush.notEnrolled": "이 브라우저는 아직 알림을 받지 않습니다.", "automations.notificationChannels.webPush.enrolled": "이 브라우저는 알림을 받습니다.", @@ -805,6 +805,9 @@ registerPack("ko", { "automations.notificationChannels.webPush.unenroll": "이 브라우저에서 사용 안 함", "automations.notificationChannels.webPush.deviceCount": "등록된 브라우저: {count}", "automations.notificationChannels.webPush.actionFailed": "이 브라우저의 알림 등록을 업데이트하지 못했습니다. 다시 시도해 주세요.", + "automations.notificationChannels.webPush.enrolledOtherAccount": "이 브라우저는 다른 계정에 등록되어 있습니다. 이 계정에도 활성화할 수 있습니다.", + "automations.notificationChannels.webPush.enableForAccount": "이 계정에 활성화", + "automations.notificationChannels.webPush.statusFailed": "이 계정의 등록 상태를 불러오지 못했습니다. 페이지를 새로고침하세요.", "automations.notificationChannels.save": "저장", "automations.notificationChannels.saved": "저장됨", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts b/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts index 2d3eee48cde..e77a1c9618a 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts @@ -797,7 +797,7 @@ registerPack("pt-BR", { "automations.notificationChannels.webOnlyHelper": "Nenhum canal de notificação está selecionado — solicitações de aprovação, avisos de autenticação e notificações de falha não serão entregues em lugar algum.", "automations.notificationChannels.webPush.deviceHeading": "Este navegador", "automations.notificationChannels.webPush.checking": "Verificando o suporte a notificações deste navegador…", - "automations.notificationChannels.webPush.unsupported": "Este navegador não oferece suporte a notificações push.", + "automations.notificationChannels.webPush.unsupported": "Notificações push não estão disponíveis neste navegador.", "automations.notificationChannels.webPush.permissionDenied": "As notificações estão bloqueadas para este site. Permita-as nas configurações do site do seu navegador e tente novamente.", "automations.notificationChannels.webPush.notEnrolled": "Este navegador ainda não recebe notificações.", "automations.notificationChannels.webPush.enrolled": "Este navegador recebe notificações.", @@ -805,6 +805,9 @@ registerPack("pt-BR", { "automations.notificationChannels.webPush.unenroll": "Desativar neste navegador", "automations.notificationChannels.webPush.deviceCount": "Navegadores registrados: {count}", "automations.notificationChannels.webPush.actionFailed": "Não foi possível atualizar o registro de notificações deste navegador. Tente novamente.", + "automations.notificationChannels.webPush.enrolledOtherAccount": "Este navegador está inscrito para outra conta. Você também pode ativá-lo para esta conta.", + "automations.notificationChannels.webPush.enableForAccount": "Ativar para esta conta", + "automations.notificationChannels.webPush.statusFailed": "Não foi possível carregar o status de inscrição desta conta. Recarregue a página.", "automations.notificationChannels.save": "Salvar", "automations.notificationChannels.saved": "Salvo", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts b/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts index 20209ac9eed..e6a0cd07947 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts @@ -797,7 +797,7 @@ registerPack("uk", { "automations.notificationChannels.webOnlyHelper": "Канал сповіщень не вибрано — запити на схвалення, підказки автентифікації та сповіщення про збої нікуди не доставлятимуться.", "automations.notificationChannels.webPush.deviceHeading": "Цей браузер", "automations.notificationChannels.webPush.checking": "Перевіряємо підтримку сповіщень у цьому браузері…", - "automations.notificationChannels.webPush.unsupported": "Цей браузер не підтримує push-сповіщення.", + "automations.notificationChannels.webPush.unsupported": "Push-сповіщення недоступні в цьому браузері.", "automations.notificationChannels.webPush.permissionDenied": "Сповіщення для цього сайту заблоковано. Дозвольте їх у налаштуваннях сайту вашого браузера й спробуйте ще раз.", "automations.notificationChannels.webPush.notEnrolled": "Цей браузер ще не отримує сповіщень.", "automations.notificationChannels.webPush.enrolled": "Цей браузер отримує сповіщення.", @@ -805,6 +805,9 @@ registerPack("uk", { "automations.notificationChannels.webPush.unenroll": "Вимкнути в цьому браузері", "automations.notificationChannels.webPush.deviceCount": "Зареєстровані браузери: {count}", "automations.notificationChannels.webPush.actionFailed": "Не вдалося оновити реєстрацію сповіщень цього браузера. Спробуйте ще раз.", + "automations.notificationChannels.webPush.enrolledOtherAccount": "Цей браузер зареєстровано для іншого облікового запису. Ви можете ввімкнути його й для цього облікового запису.", + "automations.notificationChannels.webPush.enableForAccount": "Увімкнути для цього облікового запису", + "automations.notificationChannels.webPush.statusFailed": "Не вдалося завантажити стан реєстрації цього облікового запису. Перезавантажте сторінку.", "automations.notificationChannels.save": "Зберегти", "automations.notificationChannels.saved": "Збережено", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts b/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts index 13458dca567..1e85b53433d 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts @@ -796,7 +796,7 @@ registerPack("zh-CN", { "automations.notificationChannels.webOnlyHelper": "未选择任何通知渠道——审批请求、认证提示和失败通知将不会送达任何地方。", "automations.notificationChannels.webPush.deviceHeading": "此浏览器", "automations.notificationChannels.webPush.checking": "正在检查此浏览器的通知支持…", - "automations.notificationChannels.webPush.unsupported": "此浏览器不支持推送通知。", + "automations.notificationChannels.webPush.unsupported": "此浏览器无法使用推送通知。", "automations.notificationChannels.webPush.permissionDenied": "此站点的通知已被屏蔽。请在浏览器的站点设置中允许通知,然后重试。", "automations.notificationChannels.webPush.notEnrolled": "此浏览器尚未接收通知。", "automations.notificationChannels.webPush.enrolled": "此浏览器会接收通知。", @@ -804,6 +804,9 @@ registerPack("zh-CN", { "automations.notificationChannels.webPush.unenroll": "在此浏览器中停用", "automations.notificationChannels.webPush.deviceCount": "已注册的浏览器:{count}", "automations.notificationChannels.webPush.actionFailed": "无法更新此浏览器的通知注册。请重试。", + "automations.notificationChannels.webPush.enrolledOtherAccount": "此浏览器已为另一个账户注册。你也可以为此账户启用。", + "automations.notificationChannels.webPush.enableForAccount": "为此账户启用", + "automations.notificationChannels.webPush.statusFailed": "无法加载此账户的注册状态。请重新加载页面。", "automations.notificationChannels.save": "保存", "automations.notificationChannels.saved": "已保存", "automations.notificationChannels.saveFailed": diff --git a/crates/product/ironclaw_webui/frontend/src/lib/web-push.test.ts b/crates/product/ironclaw_webui/frontend/src/lib/web-push.test.ts index 22cebdfa1ae..04ca7248cba 100644 --- a/crates/product/ironclaw_webui/frontend/src/lib/web-push.test.ts +++ b/crates/product/ironclaw_webui/frontend/src/lib/web-push.test.ts @@ -1,5 +1,6 @@ // @ts-nocheck import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { afterEach, test, vi } from "vitest"; vi.mock("./api", () => ({ @@ -9,6 +10,7 @@ vi.mock("./api", () => ({ import { subscribeWebPush, unsubscribeWebPush } from "./api"; import { + endpointDigestHex, enrollThisBrowser, getWebPushBrowserState, registerServiceWorker, @@ -43,11 +45,15 @@ afterEach(() => { vi.clearAllMocks(); }); +function sha256Hex(value) { + return createHash("sha256").update(value).digest("hex"); +} + function browserEnvironment({ permission = "default", subscription = null, subscribeResult = null, - registrationOverrides = {}, + hasRegistration = true, } = {}) { const subscribeCalls = []; const registration = { @@ -59,7 +65,6 @@ function browserEnvironment({ throw new Error("no subscribe result configured"); }, }, - ...registrationOverrides, }; const registerCalls = []; setGlobal("navigator", { @@ -69,7 +74,11 @@ function browserEnvironment({ registerCalls.push(url); return registration; }, - ready: Promise.resolve(registration), + // The module must never await `serviceWorker.ready` — it hangs forever + // when no registration exists — so the fake only offers the prompt + // `getRegistration()` probe, and `undefined` models the failed-boot- + // registration case. + getRegistration: async () => (hasRegistration ? registration : undefined), }, }); setGlobal("window", { PushManager: function PushManager() {} }); @@ -100,6 +109,13 @@ test("urlBase64ToUint8Array decodes an unpadded base64url key", () => { assert.throws(() => urlBase64ToUint8Array(""), /base64url key is required/); }); +test("endpointDigestHex matches an independent SHA-256 and rejects junk", async () => { + const endpoint = "https://fcm.googleapis.com/fcm/send/abc"; + assert.equal(await endpointDigestHex(endpoint), sha256Hex(endpoint)); + assert.equal(await endpointDigestHex(""), null); + assert.equal(await endpointDigestHex(undefined), null); +}); + test("registerServiceWorker registers /sw.js and swallows failures", async () => { const { registerCalls } = browserEnvironment(); await registerServiceWorker(); @@ -137,7 +153,40 @@ test("getWebPushBrowserState distinguishes unsupported, denied, not-enrolled, an assert.deepEqual(await getWebPushBrowserState(), { state: "enrolled", endpoint: "https://fcm.googleapis.com/fcm/send/abc", + accountMatch: null, + }); +}); + +test("getWebPushBrowserState resolves promptly as unsupported when no registration exists", async () => { + // A failed boot registration leaves getRegistration() → undefined; + // `serviceWorker.ready` would hang forever here, which is the regression + // this pins (CodeRabbit stability finding on PR #7398). + browserEnvironment({ permission: "granted", hasRegistration: false }); + assert.deepEqual(await getWebPushBrowserState(), { state: "unsupported" }); +}); + +test("getWebPushBrowserState correlates the subscription with the account's endpoint digests", async () => { + const endpoint = "https://fcm.googleapis.com/fcm/send/mine"; + browserEnvironment({ permission: "granted", subscription: fakeSubscription(endpoint) }); + + const matched = await getWebPushBrowserState({ + accountEndpointDigests: [sha256Hex(endpoint)], + }); + assert.deepEqual(matched, { state: "enrolled", endpoint, accountMatch: true }); + + const matchedCaseInsensitive = await getWebPushBrowserState({ + accountEndpointDigests: [sha256Hex(endpoint).toUpperCase()], }); + assert.equal(matchedCaseInsensitive.accountMatch, true); + + // Another account's browser subscription: digests exist, none match. + const foreign = await getWebPushBrowserState({ + accountEndpointDigests: [sha256Hex("https://fcm.googleapis.com/fcm/send/other")], + }); + assert.deepEqual(foreign, { state: "enrolled", endpoint, accountMatch: false }); + + const emptyAccount = await getWebPushBrowserState({ accountEndpointDigests: [] }); + assert.equal(emptyAccount.accountMatch, false, "an empty digest list is a definite non-match"); }); test("enrollThisBrowser subscribes with the VAPID key and registers with the backend", async () => { @@ -152,6 +201,7 @@ test("enrollThisBrowser subscribes with the VAPID key and registers with the bac assert.deepEqual(state, { state: "enrolled", endpoint: "https://fcm.googleapis.com/fcm/send/new", + accountMatch: true, }); assert.equal(subscribeCalls.length, 1); assert.equal(subscribeCalls[0].userVisibleOnly, true); @@ -164,6 +214,34 @@ test("enrollThisBrowser subscribes with the VAPID key and registers with the bac }); }); +test("enrollThisBrowser rolls back a freshly created subscription when the backend rejects", async () => { + const created = fakeSubscription("https://fcm.googleapis.com/fcm/send/fresh"); + browserEnvironment({ + permission: "granted", + subscription: null, + subscribeResult: created, + }); + subscribeWebPush.mockRejectedValueOnce(new Error("backend rejected")); + + await assert.rejects(enrollThisBrowser({ vapidPublicKey: "AQAB" }), /backend rejected/); + assert.equal( + created.unsubscribe.mock.calls.length, + 1, + "a created-but-unregistered subscription must be unsubscribed so the browser never reports enrolled without a server record", + ); +}); + +test("enrollThisBrowser never unsubscribes a pre-existing subscription on backend failure", async () => { + // The pre-existing subscription may belong to ANOTHER account in this + // browser profile; rolling it back would sever that account's enrollment. + const existing = fakeSubscription("https://fcm.googleapis.com/fcm/send/other-account"); + browserEnvironment({ permission: "granted", subscription: existing }); + subscribeWebPush.mockRejectedValueOnce(new Error("backend rejected")); + + await assert.rejects(enrollThisBrowser({ vapidPublicKey: "AQAB" }), /backend rejected/); + assert.equal(existing.unsubscribe.mock.calls.length, 0); +}); + test("enrollThisBrowser reports a denied permission without subscribing", async () => { const { subscribeCalls } = browserEnvironment({ permission: "denied" }); const state = await enrollThisBrowser({ vapidPublicKey: "AQAB" }); diff --git a/crates/product/ironclaw_webui/frontend/src/lib/web-push.ts b/crates/product/ironclaw_webui/frontend/src/lib/web-push.ts index 47b0551d5f9..4c826817f6a 100644 --- a/crates/product/ironclaw_webui/frontend/src/lib/web-push.ts +++ b/crates/product/ironclaw_webui/frontend/src/lib/web-push.ts @@ -36,12 +36,43 @@ function pushSupported() { } async function pushRegistration() { - // `ready` resolves once the boot-time registration activates; it never - // rejects for a registered worker, but guard a missing registration in - // browsers that lost it (e.g. cleared site data mid-session). - const registration = await navigator.serviceWorker.ready; - if (!registration || !registration.pushManager) return null; - return registration; + // Deliberately `getRegistration()`, never `serviceWorker.ready`: `ready` + // resolves only once SOME registration activates and never rejects, so + // after a failed boot registration (non-secure origin, /sw.js 404, + // private mode) it hangs forever and every state probe hangs with it. + // `getRegistration()` resolves promptly with `undefined` in exactly those + // cases, which callers surface as "unsupported". + if (!navigator.serviceWorker || typeof navigator.serviceWorker.getRegistration !== "function") { + return null; + } + try { + const registration = await navigator.serviceWorker.getRegistration(); + if (!registration || !registration.pushManager) return null; + return registration; + } catch (_) { + return null; + } +} + +/** Lowercase hex SHA-256 of the endpoint URL string — the correlation key + * the backend exposes as `endpoint_digest` on `GET /web-push/status`, so the + * browser can tell whether ITS subscription belongs to the signed-in account + * without the backend ever echoing full endpoint capability URLs. Returns + * null when WebCrypto is unavailable (push itself requires a secure context, + * so this is effectively test-environment-only). */ +export async function endpointDigestHex(endpoint) { + if (typeof endpoint !== "string" || !endpoint) return null; + const subtle = globalThis.crypto && globalThis.crypto.subtle; + if (!subtle) return null; + try { + const bytes = new TextEncoder().encode(endpoint); + const digest = await subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + } catch (_) { + return null; + } } /** @@ -49,19 +80,39 @@ async function pushRegistration() { * { state: "unsupported" } * { state: "permission-denied" } * { state: "not-enrolled" } - * { state: "enrolled", endpoint } + * { state: "enrolled", endpoint, accountMatch } + * + * `accountMatch` correlates the browser-global subscription with the + * SIGNED-IN account's enrollment set (`accountEndpointDigests`, the + * `endpoint_digest` values from `GET /web-push/status`): + * true — this account holds a record for this browser's subscription; + * false — a subscription exists but belongs to no record of this account + * (typically another account enrolled in this browser profile); + * null — correlation unavailable (no digest list supplied, or WebCrypto + * missing). + * Callers must only offer a local unsubscribe when `accountMatch === true`; + * anything else risks severing another account's enrollment. */ -export async function getWebPushBrowserState() { +export async function getWebPushBrowserState({ accountEndpointDigests = null } = {}) { if (!pushSupported()) return { state: "unsupported" }; if (Notification.permission === "denied") return { state: "permission-denied" }; try { const registration = await pushRegistration(); if (!registration) return { state: "unsupported" }; const subscription = await registration.pushManager.getSubscription(); - if (subscription && subscription.endpoint) { - return { state: "enrolled", endpoint: subscription.endpoint }; + if (!subscription || !subscription.endpoint) { + return { state: "not-enrolled" }; } - return { state: "not-enrolled" }; + let accountMatch = null; + if (Array.isArray(accountEndpointDigests)) { + const digest = await endpointDigestHex(subscription.endpoint); + if (digest) { + accountMatch = accountEndpointDigests.some( + (candidate) => typeof candidate === "string" && candidate.toLowerCase() === digest, + ); + } + } + return { state: "enrolled", endpoint: subscription.endpoint, accountMatch }; } catch (_) { return { state: "not-enrolled" }; } @@ -93,7 +144,12 @@ function subscriptionKeys(subscription) { } /** Ask for permission, subscribe this browser, and register the - * subscription with the backend. Returns the resulting browser state. */ + * subscription with the backend. Returns the resulting browser state. + * + * Also the "enable for this account" path when the browser already holds a + * subscription enrolled by a different account: the existing subscription is + * reused as-is and registered under the current caller — never unsubscribed, + * so the other account's enrollment is left intact. */ export async function enrollThisBrowser({ vapidPublicKey } = {}) { if (!vapidPublicKey) { throw new Error("vapidPublicKey is required"); @@ -108,22 +164,43 @@ export async function enrollThisBrowser({ vapidPublicKey } = {}) { const registration = await pushRegistration(); if (!registration) return { state: "unsupported" }; let subscription = await registration.pushManager.getSubscription(); + let createdSubscription = false; if (!subscription) { subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }); + createdSubscription = true; + } + try { + await subscribeWebPush({ + endpoint: subscription.endpoint, + keys: subscriptionKeys(subscription), + userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined, + }); + } catch (error) { + // Evidence rule: the browser must never report "enrolled" without a + // server record. Roll back a subscription THIS call created; a + // pre-existing one is left alone (it may back another account). + if (createdSubscription) { + try { + await subscription.unsubscribe(); + } catch (rollbackError) { + console.warn("web push enrollment rollback failed", rollbackError); + } + } + throw error; } - await subscribeWebPush({ - endpoint: subscription.endpoint, - keys: subscriptionKeys(subscription), - userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined, - }); - return { state: "enrolled", endpoint: subscription.endpoint }; + return { state: "enrolled", endpoint: subscription.endpoint, accountMatch: true }; } /** Unsubscribe this browser locally and remove it from the backend. - * Returns the resulting browser state. */ + * Returns the resulting browser state. + * + * Only call this when the subscription is verified to belong to the current + * account (`accountMatch === true` from [`getWebPushBrowserState`]): the + * push subscription is browser-global, so unsubscribing it on behalf of the + * wrong account would silently break the owning account's notifications. */ export async function unenrollThisBrowser() { if (!pushSupported()) return { state: "unsupported" }; const registration = await pushRegistration(); diff --git a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts index 4165eb774f4..1357893fa2f 100644 --- a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts +++ b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts @@ -18,7 +18,7 @@ const COPY = { "automations.notificationChannels.webPush.checking": "Checking this browser's notification support…", "automations.notificationChannels.webPush.unsupported": - "This browser doesn't support push notifications.", + "Push notifications aren't available in this browser.", "automations.notificationChannels.webPush.permissionDenied": "Notifications are blocked for this site. Allow them in your browser's site settings, then try again.", "automations.notificationChannels.webPush.notEnrolled": @@ -31,6 +31,11 @@ const COPY = { "automations.notificationChannels.webPush.deviceCount": "Enrolled browsers: {count}", "automations.notificationChannels.webPush.actionFailed": "Couldn't update this browser's notification enrollment. Please try again.", + "automations.notificationChannels.webPush.enrolledOtherAccount": + "This browser is enrolled for a different account. You can enable it for this account too.", + "automations.notificationChannels.webPush.enableForAccount": "Enable for this account", + "automations.notificationChannels.webPush.statusFailed": + "Couldn't load this account's enrollment status. Try reloading the page.", "automations.notificationChannels.save": "Save", "automations.notificationChannels.saved": "Saved", "automations.notificationChannels.saveFailed": @@ -712,7 +717,7 @@ test("WebPushDeviceBlock distinguishes unsupported, denied, not-enrolled, and en const cases = [ [ { state: "unsupported" }, - "This browser doesn't support push notifications.", + "Push notifications aren't available in this browser.", ], [ { state: "permission-denied" }, @@ -723,7 +728,23 @@ test("WebPushDeviceBlock distinguishes unsupported, denied, not-enrolled, and en "This browser isn't receiving notifications yet.", ], [ - { state: "enrolled", endpoint: "https://fcm.googleapis.com/send/x" }, + { state: "enrolled", endpoint: "https://fcm.googleapis.com/send/x", accountMatch: true }, + "This browser receives notifications.", + ], + [ + { + state: "enrolled-other-account", + endpoint: "https://fcm.googleapis.com/send/x", + accountMatch: false, + }, + "This browser is enrolled for a different account. You can enable it for this account too.", + ], + [ + { + state: "enrolled-unverified", + endpoint: "https://fcm.googleapis.com/send/x", + accountMatch: null, + }, "This browser receives notifications.", ], ]; @@ -802,3 +823,102 @@ test("WebPushDeviceBlock surfaces an action failure", () => { ), ); }); + +test("WebPushDeviceBlock offers enable-for-this-account (never disable) for another account's subscription", () => { + const harness = createHarness(); + const block = harness.exports.WebPushDeviceBlock; + + const enrollCalls = []; + const unenrollCalls = []; + const rendered = block({ + device: fakeDevice({ + browser: { + state: "enrolled-other-account", + endpoint: "https://fcm.googleapis.com/send/x", + accountMatch: false, + }, + enroll: async () => { + enrollCalls.push("enroll"); + return { state: "enrolled" }; + }, + unenroll: async () => { + unenrollCalls.push("unenroll"); + return { state: "not-enrolled" }; + }, + }), + t, + }); + const buttons = componentProps(rendered, harness.Button); + assert.equal( + buttons.length, + 1, + "exactly one action for another account's subscription — no local disable that would sever it", + ); + const scalars = collectScalars(rendered); + assert.ok(scalars.includes("Enable for this account")); + assert.ok( + !scalars.includes("Disable in this browser"), + "the local unsubscribe must not be offered for a subscription this account does not own", + ); + buttons[0].onClick(); + assert.deepEqual(enrollCalls, ["enroll"], "the action routes through the enroll flow"); + assert.deepEqual(unenrollCalls, []); +}); + +test("WebPushDeviceBlock offers no actions for an unverified enrollment", () => { + const harness = createHarness(); + const block = harness.exports.WebPushDeviceBlock; + const rendered = block({ + device: fakeDevice({ + browser: { + state: "enrolled-unverified", + endpoint: "https://fcm.googleapis.com/send/x", + accountMatch: null, + }, + statusError: new Error("status query failed"), + }), + t, + }); + assert.equal( + componentProps(rendered, harness.Button).length, + 0, + "without account correlation neither enroll nor the destructive disable is offered", + ); +}); + +test("WebPushDeviceBlock renders the status failure instead of claiming zero enrolled browsers", () => { + const harness = createHarness(); + const block = harness.exports.WebPushDeviceBlock; + const rendered = block({ + device: fakeDevice({ statusError: new Error("status query failed") }), + t, + }); + const scalars = collectScalars(rendered); + assert.ok( + scalars.includes("Couldn't load this account's enrollment status. Try reloading the page."), + "a failed status query must be announced", + ); + assert.ok( + !scalars.includes("Enrolled browsers: 0"), + "a failed status query must not be rendered as a truthful zero-device count", + ); +}); + +test("WebPushDeviceBlock disables enroll while the VAPID key is missing", () => { + // `vapidPublicKey` is "" until the status query resolves (or after it + // fails). The enroll click would then always throw `vapidPublicKey is + // required`, so the gate on the key is load-bearing — pin it. + const harness = createHarness(); + const block = harness.exports.WebPushDeviceBlock; + const rendered = block({ + device: fakeDevice({ vapidPublicKey: "" }), + t, + }); + const [enrollButton] = componentProps(rendered, harness.Button); + assert.ok(enrollButton, "the enroll button still renders"); + assert.equal( + enrollButton.disabled, + true, + "enroll must stay disabled until the VAPID key is available", + ); +}); diff --git a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx index a6fac4c1945..2c49097716d 100644 --- a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx +++ b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx @@ -32,19 +32,34 @@ function rowTone(status) { */ function WebPushDeviceBlock({ device, t }) { const state = device.browser?.state || "checking"; + const hasStatusError = Boolean(device.statusError); let stateCopy; if (state === "unsupported") { stateCopy = t("automations.notificationChannels.webPush.unsupported"); } else if (state === "permission-denied") { stateCopy = t("automations.notificationChannels.webPush.permissionDenied"); - } else if (state === "enrolled") { + } else if (state === "enrolled" || state === "enrolled-unverified") { + // "enrolled-unverified" (correlation unavailable — e.g. the status query + // failed) renders the enrolled copy but exposes no disable action; the + // status-error line below explains why the panel knows less than usual. stateCopy = t("automations.notificationChannels.webPush.enrolled"); + } else if (state === "enrolled-other-account") { + stateCopy = t("automations.notificationChannels.webPush.enrolledOtherAccount"); } else if (state === "not-enrolled") { stateCopy = t("automations.notificationChannels.webPush.notEnrolled"); } else { stateCopy = t("automations.notificationChannels.webPush.checking"); } - const canEnroll = state === "not-enrolled" && !device.isBusy && device.vapidPublicKey; + // The enroll flow also serves "enable for this account" when another + // account's subscription occupies this browser: the same backend register + // reuses the existing subscription without touching the other enrollment. + const canEnroll = + (state === "not-enrolled" || state === "enrolled-other-account") && + !device.isBusy && + device.vapidPublicKey; + // Local unsubscribe is offered ONLY for a verified own-account enrollment: + // the push subscription is browser-global, so disabling from any other + // state could sever a different account's notifications. const canUnenroll = state === "enrolled" && !device.isBusy; return (
{stateCopy}
-
- {t("automations.notificationChannels.webPush.deviceCount", { - count: device.subscriptionCount, - })} -
- {(state === "not-enrolled" || state === "enrolled") && + {hasStatusError + ? ( +
+ {t("automations.notificationChannels.webPush.statusFailed")} +
+ ) + : ( +
+ {t("automations.notificationChannels.webPush.deviceCount", { + count: device.subscriptionCount, + })} +
+ )} + {(state === "not-enrolled" || state === "enrolled" || state === "enrolled-other-account") && (
- {state === "not-enrolled" && + {(state === "not-enrolled" || state === "enrolled-other-account") && ( )} {state === "enrolled" && diff --git a/crates/product/ironclaw_webui/frontend/src/pages/automations/hooks/useWebPushDevice.ts b/crates/product/ironclaw_webui/frontend/src/pages/automations/hooks/useWebPushDevice.ts index a5336ee7e15..7513210bd70 100644 --- a/crates/product/ironclaw_webui/frontend/src/pages/automations/hooks/useWebPushDevice.ts +++ b/crates/product/ironclaw_webui/frontend/src/pages/automations/hooks/useWebPushDevice.ts @@ -10,6 +10,25 @@ import { const WEB_PUSH_STATUS_QUERY_KEY = ["web-push", "status"]; +/** + * Derive the panel-facing device state from the raw browser probe plus the + * account correlation: + * "checking" | "unsupported" | "permission-denied" | "not-enrolled" + * "enrolled" — subscription verified to belong to THIS account + * "enrolled-other-account" — a subscription exists but this account holds + * no record for it (another account enrolled in + * this browser profile) + * "enrolled-unverified" — a subscription exists but correlation was + * unavailable (e.g. the status query failed); + * no disable action is offered from this state. + */ +function deriveDeviceState(probe) { + if (!probe || probe.state !== "enrolled") return probe?.state || "checking"; + if (probe.accountMatch === true) return "enrolled"; + if (probe.accountMatch === false) return "enrolled-other-account"; + return "enrolled-unverified"; +} + /** * Per-browser web-push device state for the notification-channels panel's * "Web app" row: the account-level enrollment summary (backend status view) @@ -17,7 +36,11 @@ const WEB_PUSH_STATUS_QUERY_KEY = ["web-push", "status"]; * * The account-level toggle (whether the web-push target is a notification * channel) stays in the ordinary draft/save set — this hook only manages the - * device dimension underneath it. + * device dimension underneath it. Browser state is correlated with the + * signed-in account through the status view's `endpoint_digest` values, so a + * subscription enrolled by a DIFFERENT account in the same browser profile + * is never presented as this account's enrollment (and never offered a + * local unsubscribe that would sever the other account). */ export function useWebPushDevice() { const queryClient = useQueryClient(); @@ -26,24 +49,34 @@ export function useWebPushDevice() { queryFn: getWebPushStatus, }); - const [browser, setBrowser] = React.useState({ state: "checking" }); + const [probe, setProbe] = React.useState(null); + // A post-action probe result is authoritative until the status refetch + // settles; the effect below only overwrites it once fresh data arrives. + const statusData = statusQuery.data; + const statusSettled = !statusQuery.isLoading; React.useEffect(() => { + if (!statusSettled) return undefined; let cancelled = false; - getWebPushBrowserState().then( + const accountEndpointDigests = statusData + ? (statusData.subscriptions || []) + .map((subscription) => subscription.endpoint_digest) + .filter((digest) => typeof digest === "string" && digest) + : null; + getWebPushBrowserState({ accountEndpointDigests }).then( (state) => { - if (!cancelled) setBrowser(state); + if (!cancelled) setProbe(state); }, () => { - if (!cancelled) setBrowser({ state: "unsupported" }); + if (!cancelled) setProbe({ state: "unsupported" }); }, ); return () => { cancelled = true; }; - }, []); + }, [statusSettled, statusData]); const refreshAfterAction = async (nextState) => { - setBrowser(nextState); + setProbe(nextState); await queryClient.invalidateQueries({ queryKey: WEB_PUSH_STATUS_QUERY_KEY }); return nextState; }; @@ -61,7 +94,7 @@ export function useWebPushDevice() { }); return { - browser, + browser: { ...(probe || {}), state: deriveDeviceState(probe) }, subscriptionCount: statusQuery.data?.subscription_count ?? 0, vapidPublicKey: statusQuery.data?.vapid_public_key || "", isStatusLoading: statusQuery.isLoading, diff --git a/crates/product/ironclaw_webui/src/webui_v2/handlers.rs b/crates/product/ironclaw_webui/src/webui_v2/handlers.rs index 6c6998ded2a..2a3134eed3e 100644 --- a/crates/product/ironclaw_webui/src/webui_v2/handlers.rs +++ b/crates/product/ironclaw_webui/src/webui_v2/handlers.rs @@ -114,11 +114,11 @@ use ironclaw_product_contracts::product_wire::{ RebornTraceHoldAuthorizeProductRequest, RebornTraceHoldAuthorizeResponse, SettingsToolPermissionState, }; -use ironclaw_product_contracts::views::{RebornViewDescriptor, RebornViewPage, RebornViewQuery}; use ironclaw_product_contracts::product_wire::{ RebornWebPushStatusResponse, RebornWebPushSubscribeRequest, RebornWebPushSubscribeResponse, RebornWebPushUnsubscribeRequest, RebornWebPushUnsubscribeResponse, }; +use ironclaw_product_contracts::views::{RebornViewDescriptor, RebornViewPage, RebornViewQuery}; use ironclaw_product_contracts::web_push::{ WEB_PUSH_STATUS_VIEW, WEB_PUSH_SUBSCRIBE_COMMAND, WEB_PUSH_UNSUBSCRIBE_COMMAND, }; diff --git a/tests/integration/webui_v2_product_api.rs b/tests/integration/webui_v2_product_api.rs index 8a803e3aca1..30b96d74090 100644 --- a/tests/integration/webui_v2_product_api.rs +++ b/tests/integration/webui_v2_product_api.rs @@ -2537,6 +2537,19 @@ async fn web_push_enrollment_and_notification_channel_round_trip_through_product body["subscriptions"][0]["endpoint_host"], "fcm.googleapis.com", "{body}" ); + // The endpoint is redacted to its host, but a 64-char hex digest is + // published so the browser can correlate its own subscription with this + // account without the URL ever leaving the backend. + let digest = body["subscriptions"][0]["endpoint_digest"] + .as_str() + .expect("endpoint_digest present"); + assert_eq!(digest.len(), 64, "SHA-256 hex digest: {body}"); + assert!( + digest + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + "digest must be lowercase hex: {body}" + ); assert!( !body.to_string().contains(ENDPOINT), "the full endpoint capability URL must never leave the backend: {body}" From cfcb1d4a917bc783e326987c3119eb35e8537e12 Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sat, 8 Aug 2026 20:04:05 -0400 Subject: [PATCH 03/13] fix(web-push): keep SW registration out of the initial /chat bundle; raise the chat budget for the feature's i18n copy The merge-lane bundle-budget gate (skipped on the PR lane) flagged the initial /chat JS at 218.2 KB against the 217.0 KB gzip budget. `main.tsx` boot-imported `registerServiceWorker` from `lib/web-push.ts`, which dragged the enrollment lib's api-client and WebCrypto imports into the initial chunk. Extracted the dependency-free `lib/register-sw.ts` for boot; the enrollment API stays in `web-push.ts`, imported only by the already-lazy automations route. That recovered the eager-code weight (218.2 -> 217.4 KB); the residual is the feature's new `en.ts` fallback-pack strings, so the /chat budget is raised 217.0 -> 218.0 KB with rationale, matching how prior features (hosted MCP, Router 8) handled eager localized copy. Co-Authored-By: Claude Fable 5 --- .../frontend/scripts/check-bundle-budgets.ts | 10 ++++++++- .../frontend/src/lib/register-sw.ts | 22 +++++++++++++++++++ .../frontend/src/lib/web-push.ts | 20 +++++------------ .../ironclaw_webui/frontend/src/main.tsx | 2 +- 4 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 crates/product/ironclaw_webui/frontend/src/lib/register-sw.ts diff --git a/crates/product/ironclaw_webui/frontend/scripts/check-bundle-budgets.ts b/crates/product/ironclaw_webui/frontend/scripts/check-bundle-budgets.ts index d1dc3ada7e8..f4f4fd84756 100644 --- a/crates/product/ironclaw_webui/frontend/scripts/check-bundle-budgets.ts +++ b/crates/product/ironclaw_webui/frontend/scripts/check-bundle-budgets.ts @@ -51,7 +51,15 @@ const LOGIN_GZIP_BUDGET = 180_000; // initial route, and prevents model-authored `data-workspace-path` metadata // from becoming trusted. The measured /chat closure is 215.8 KB gzip; 217.0 KB // retains about 1.2 KB of explicit headroom without weakening the feature. -const CHAT_GZIP_BUDGET = 217_000; +// Web Push notifications then added ~13 `automations.notificationChannels.webPush.*` +// keys plus a reworded `webOnlyHelper` to the eager `en.ts` fallback pack. The +// eager-code weight was kept OUT of /chat: `registerServiceWorker` lives in the +// dependency-free `lib/register-sw.ts` (so boot does not pull the enrollment +// lib's api + WebCrypto imports), and the enrollment UI rides the already-lazy +// automations route — so the residual growth is localized string content in the +// fallback pack, not new eager code. Measured /chat closure is now 217.4 KB +// gzip; 218.0 KB retains ~0.6 KB headroom without gutting user-facing copy. +const CHAT_GZIP_BUDGET = 218_000; const CHUNK_RAW_BUDGET = 500_000; export function resolveBundleAsset(distRoot: string, file: string): string { diff --git a/crates/product/ironclaw_webui/frontend/src/lib/register-sw.ts b/crates/product/ironclaw_webui/frontend/src/lib/register-sw.ts new file mode 100644 index 00000000000..30ce24303fe --- /dev/null +++ b/crates/product/ironclaw_webui/frontend/src/lib/register-sw.ts @@ -0,0 +1,22 @@ +// @ts-nocheck +// Boot-time service-worker registration, isolated from the web-push +// enrollment lib on purpose. `main.tsx` calls this on every startup, so it +// must stay dependency-free — pulling in the enrollment machinery +// (`web-push.ts`, which imports the api client + WebCrypto digest helpers) +// would drag all of it into the initial `/chat` bundle, which the bundle +// budget gate rejects. The automations-page enrollment API lives in +// `web-push.ts` and is imported only by that route's hook. + +const SERVICE_WORKER_URL = "/sw.js"; + +/** Register the notification service worker. Safe to call on every boot; + * failures are logged and swallowed so app startup never depends on it. */ +export function registerServiceWorker() { + if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) { + return Promise.resolve(null); + } + return navigator.serviceWorker.register(SERVICE_WORKER_URL).catch((error) => { + console.warn("IronClaw service worker registration failed", error); + return null; + }); +} diff --git a/crates/product/ironclaw_webui/frontend/src/lib/web-push.ts b/crates/product/ironclaw_webui/frontend/src/lib/web-push.ts index 4c826817f6a..98f0689f963 100644 --- a/crates/product/ironclaw_webui/frontend/src/lib/web-push.ts +++ b/crates/product/ironclaw_webui/frontend/src/lib/web-push.ts @@ -9,21 +9,11 @@ import { subscribeWebPush, unsubscribeWebPush } from "./api"; -const SERVICE_WORKER_URL = "/sw.js"; - -/** Register the notification service worker. Safe to call on every boot; - * failures are logged and swallowed so app startup never depends on it. */ -export function registerServiceWorker() { - if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) { - return Promise.resolve(null); - } - return navigator.serviceWorker - .register(SERVICE_WORKER_URL) - .catch((error) => { - console.warn("IronClaw service worker registration failed", error); - return null; - }); -} +// `registerServiceWorker` lives in the dependency-free `./register-sw` module +// so app boot (`main.tsx`) does not pull this enrollment lib — and its api + +// WebCrypto imports — into the initial `/chat` bundle. Re-exported here so the +// automations-page hook keeps one import site for the web-push surface. +export { registerServiceWorker } from "./register-sw"; function pushSupported() { return ( diff --git a/crates/product/ironclaw_webui/frontend/src/main.tsx b/crates/product/ironclaw_webui/frontend/src/main.tsx index 82f2f8cf223..84bad30b3dd 100644 --- a/crates/product/ironclaw_webui/frontend/src/main.tsx +++ b/crates/product/ironclaw_webui/frontend/src/main.tsx @@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client"; import { App } from "./app/app"; import { queryClient } from "./lib/query-client"; import { I18nProvider } from "./lib/i18n"; -import { registerServiceWorker } from "./lib/web-push"; +import { registerServiceWorker } from "./lib/register-sw"; // Only the English fallback is bundled eagerly; every other locale is // lazy-loaded on demand by I18nProvider (see lib/i18n.tsx `loaders`). import "./i18n/en"; From da8dfcd9c17e10fb730bc19efa1bb10776f71446 Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sat, 8 Aug 2026 20:15:42 -0400 Subject: [PATCH 04/13] test(web-push): register the browser channel in the journey coverage gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge-lane product-surface evidence gate (test_journey_coverage.py, skipped on the PR lane) requires every outbound channel manifest to name exact journey evidence. web-push declares `outbound = true`, so it entered the required delivery-target set with none. Added: - `JourneyDeliveryTarget.WEB_PUSH` and a `ProductJourneyCase` citing the existing `blocked_fire_pushes_web_push_notice_to_enrolled_browser` integration test, with unthreaded (`thread_anchor=None`) delivery-address evidence — browser push addresses a per-browser endpoint capability URL, not a conversation thread. - `assert_web_push_delivery_evidence` in delivery_user_journeys.rs, shaped as the gate's citability check requires (literal `expected_conversation_id` gating the count, `expected_thread_anchor = None`), called from that test. Verified: test_journey_coverage.py (68), test_product_surface_coverage.py + test_provider_capability_inventory.py (30), and the cited integration test all green. Co-Authored-By: Claude Fable 5 --- tests/e2e/journey_cases.py | 33 +++++++++++++++++++++ tests/e2e/journey_types.py | 4 +++ tests/integration/delivery_user_journeys.rs | 29 ++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/tests/e2e/journey_cases.py b/tests/e2e/journey_cases.py index 6626ac25c71..a4ace697585 100644 --- a/tests/e2e/journey_cases.py +++ b/tests/e2e/journey_cases.py @@ -450,6 +450,39 @@ def shared_world_provider_journey_runs( ), ), ), + ProductJourneyCase( + # A blocked scheduled fire fans its gate-prompt notice to the + # creator's enrolled browser through the web-push channel: one + # unthreaded push POST to the endpoint capability URL, host-injected + # VAPID authorization, RFC 8291 body. Web push has no provider world + # (the endpoint is on the manifest's declared push host, answered by + # the harness's recording network substrate), so `NONE`. + case_id="scheduled_trigger_gate_notice_reaches_web_push_browser", + provider_worlds=(ProviderWorld.NONE,), + mutable_provider_worlds=(), + ingress=JourneyIngress.SCHEDULED_TRIGGER, + execution=JourneyExecution.REBORN_INTEGRATION, + delivery_target=JourneyDeliveryTarget.WEB_PUSH, + assertions=( + ObservableAssertion.DURABLE_STATE, + ObservableAssertion.EXACT_DESTINATION, + ObservableAssertion.EXACT_MUTATION_COUNT, + ObservableAssertion.CREDENTIAL_INJECTION, + ), + evidence=CargoEvidence( + source="tests/integration/delivery_user_journeys.rs", + test="blocked_fire_pushes_web_push_notice_to_enrolled_browser", + target="reborn_integration_delivery_user_journeys", + ), + delivery_addresses=( + DeliveryAddressEvidence( + conversation_id="https://fcm.googleapis.com/fcm/send/live-subscription-token", + thread_anchor=None, + exact_count=1, + assertion="assert_web_push_delivery_evidence", + ), + ), + ), ) ALL_JOURNEY_CASES = (*PROVIDER_JOURNEY_CASES, *PRODUCT_JOURNEY_CASES) diff --git a/tests/e2e/journey_types.py b/tests/e2e/journey_types.py index e32c2ccffd4..7ef947e18ce 100644 --- a/tests/e2e/journey_types.py +++ b/tests/e2e/journey_types.py @@ -35,6 +35,10 @@ class JourneyDeliveryTarget(StrEnum): WEBUI = "webui" SLACK = "slack" TELEGRAM = "telegram" + #: The web-push browser channel. Outbound-only, unthreaded — deliveries + #: address a per-browser endpoint capability URL, never a conversation + #: thread. + WEB_PUSH = "web-push" #: The journey ends before any reply is delivered. Ingress proofs that #: stop at durable turn admission use this rather than naming a target #: they never reach -- claiming one would make the inventory read as diff --git a/tests/integration/delivery_user_journeys.rs b/tests/integration/delivery_user_journeys.rs index 99cc51dabc0..a223479214d 100644 --- a/tests/integration/delivery_user_journeys.rs +++ b/tests/integration/delivery_user_journeys.rs @@ -1927,6 +1927,32 @@ const WEB_PUSH_LIVE_ENDPOINT: &str = "https://fcm.googleapis.com/fcm/send/live-s const WEB_PUSH_GONE_ENDPOINT: &str = "https://fcm.googleapis.com/fcm/send/gone-subscription-token"; const WEB_PUSH_TARGET_ID: &str = "web-push"; +/// Exact-destination + unthreaded delivery evidence for the journey coverage +/// gate (`tests/e2e/scenarios/test_journey_coverage.py` +/// `_assert_delivery_address_is_citable`). Web push addresses a per-browser +/// endpoint capability URL, not a threaded conversation — the endpoint IS the +/// destination and there is no thread anchor. The gate greps this helper for +/// `expected_conversation_id`/`expected_thread_anchor` gating the count. +fn assert_web_push_delivery_evidence(posts: &[ironclaw_network::NetworkHttpRequest]) { + // Literal (not the `WEB_PUSH_LIVE_ENDPOINT` const) because the journey + // coverage gate greps this body for the exact destination string. + let expected_conversation_id = "https://fcm.googleapis.com/fcm/send/live-subscription-token"; + let expected_thread_anchor: Option<&str> = None; + let expected_count = 1; + let matching = posts.iter().filter(|post| { + // The endpoint carries no in-URL thread segment; browser push has no + // threading, so the anchor is unconditionally absent. + let thread_anchor: Option<&str> = None; + post.url == expected_conversation_id && thread_anchor == expected_thread_anchor + }); + assert_eq!( + matching.count(), + expected_count, + "exactly one unthreaded push POST must reach the enrolled endpoint; got {:?}", + posts.iter().map(|post| post.url.clone()).collect::>() + ); +} + /// Enroll one browser for the harness creator through the REAL WebUI route /// (`POST /api/webchat/v2/web-push/subscriptions`) over the composed /// runtime's production product surface — the same path the browser panel @@ -2109,6 +2135,9 @@ async fn blocked_fire_pushes_web_push_notice_to_enrolled_browser() { // Wire seam: exactly one push POST to the enrolled endpoint, carrying the // host-injected VAPID authorization and the RFC 8188/8291 framing. let posts = wait_for_push_posts(&harness, WEB_PUSH_LIVE_ENDPOINT, 1).await; + // Exact-destination + unthreaded evidence for the journey coverage gate + // (`tests/e2e/scenarios/test_journey_coverage.py`). + assert_web_push_delivery_evidence(&posts); assert_eq!(posts.len(), 1, "one enrolled browser, one push POST"); let post = &posts[0]; let header = |name: &str| { From a73f1bbbcd90a4667d84f47d7dbbbc070600fd88 Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sat, 8 Aug 2026 20:21:54 -0400 Subject: [PATCH 05/13] style(web-push): rustfmt the journey-evidence helper `cargo fmt --all -- --check` (the first step of the Fast deterministic checks lane, PR-lane-skipped) flagged the iterator chain in `assert_web_push_delivery_evidence`. Formatting only, no behavior change. Co-Authored-By: Claude Fable 5 --- tests/integration/delivery_user_journeys.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integration/delivery_user_journeys.rs b/tests/integration/delivery_user_journeys.rs index a223479214d..834fb02324b 100644 --- a/tests/integration/delivery_user_journeys.rs +++ b/tests/integration/delivery_user_journeys.rs @@ -1949,7 +1949,10 @@ fn assert_web_push_delivery_evidence(posts: &[ironclaw_network::NetworkHttpReque matching.count(), expected_count, "exactly one unthreaded push POST must reach the enrolled endpoint; got {:?}", - posts.iter().map(|post| post.url.clone()).collect::>() + posts + .iter() + .map(|post| post.url.clone()) + .collect::>() ); } From 03ba627b4c54a75f1c48e866e8258a1a102c89d7 Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sun, 9 Aug 2026 13:21:47 -0400 Subject: [PATCH 06/13] feat(web-push): present the channel as "Web UI" and hide it from the install catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web-app's browser-push channel is host infrastructure, not a browse-and-install integration, so: - Rename the extension/channel to "Web UI" (manifest name + channel display_name + first-party bundle label; description reworded). - Hide it from the install catalog (/extensions, /extensions/registry, and the Settings channels list) via an explicit built-in-host-surface classification in the product lifecycle projection — deliberately keyed by id, not inferred from channel direction, so it stays correct as the web-app channel later gains inbound/outbound. Its outbound notification target (/outbound/targets) is a separate registry and is unaffected, so it remains a selectable notification channel. Also finalizes the enrollment UI carried from this session: - Align the "This browser" device block with the channel cards (drop the stray left indent). - Lazy-mount the web-push device hook so its status query fires only when a web-push row is present, not on every automations view. - Rename the misleading webOnlyHelper i18n key to noSelectionHelper across all locale packs (the string already dropped the retired "stays in the web app" claim). - Manifest icons declare purpose "any maskable" so Chrome offers the PWA install prompt. Regression coverage extends the production web-push integration test to assert it is absent from the install endpoints yet present in /outbound/targets. Co-Authored-By: Claude Opus 4.8 --- .../ironclaw_cli/src/first_party/bundles.rs | 2 +- .../packages/web-push/manifest.toml | 6 ++-- crates/product/ironclaw_assistant/AGENTS.md | 2 +- .../src/reborn_services/extensions.rs | 21 +++++++++++- .../frontend/public/assets/site.webmanifest | 4 +-- .../ironclaw_webui/frontend/src/i18n/ar.ts | 2 +- .../ironclaw_webui/frontend/src/i18n/de.ts | 2 +- .../ironclaw_webui/frontend/src/i18n/en.ts | 2 +- .../ironclaw_webui/frontend/src/i18n/es.ts | 2 +- .../ironclaw_webui/frontend/src/i18n/fr.ts | 2 +- .../ironclaw_webui/frontend/src/i18n/hi.ts | 2 +- .../ironclaw_webui/frontend/src/i18n/ja.ts | 2 +- .../ironclaw_webui/frontend/src/i18n/ko.ts | 2 +- .../ironclaw_webui/frontend/src/i18n/pt-BR.ts | 2 +- .../ironclaw_webui/frontend/src/i18n/uk.ts | 2 +- .../ironclaw_webui/frontend/src/i18n/zh-CN.ts | 2 +- .../frontend/src/lib/api.test.ts | 4 +-- .../notification-channels-panel.test.ts | 32 +++++++++++++------ .../notification-channels-panel.tsx | 25 +++++++++++---- tests/integration/webui_v2_product_api.rs | 24 ++++++++++++++ 20 files changed, 106 insertions(+), 36 deletions(-) diff --git a/crates/app/ironclaw_cli/src/first_party/bundles.rs b/crates/app/ironclaw_cli/src/first_party/bundles.rs index 414805fbf19..d0224b561bf 100644 --- a/crates/app/ironclaw_cli/src/first_party/bundles.rs +++ b/crates/app/ironclaw_cli/src/first_party/bundles.rs @@ -77,7 +77,7 @@ pub(crate) fn bundled_first_party_bundles() -> Vec { // cross-crate include inventory instead. bundles.push(FirstPartyPackageBundle { id: ironclaw_web_push::WEB_PUSH_EXTENSION_ID.to_string(), - display_name: "Browser notifications".to_string(), + display_name: "Web UI".to_string(), manifest_toml: ironclaw_web_push_extension::MANIFEST.to_string(), assets: vec![FirstPartyPackageAsset { path: "manifest.toml".to_string(), diff --git a/crates/extensions/packages/web-push/manifest.toml b/crates/extensions/packages/web-push/manifest.toml index 0638f61cc71..7aeee14d6f0 100644 --- a/crates/extensions/packages/web-push/manifest.toml +++ b/crates/extensions/packages/web-push/manifest.toml @@ -5,9 +5,9 @@ # seeded by the host at boot (never typed by an operator). schema_version = "reborn.extension_manifest.v3" id = "web-push" -name = "Browser notifications" +name = "Web UI" version = "0.1.0" -description = "Web Push notifications to the web app's enrolled browsers" +description = "Web Push notifications to the web UI's enrolled browsers" trust = "first_party_requested" [runtime] @@ -37,7 +37,7 @@ fields = [ [channel] id = "notifications" -display_name = "Browser notifications" +display_name = "Web UI" inbound = false outbound = true conversation_model = "continuous" diff --git a/crates/product/ironclaw_assistant/AGENTS.md b/crates/product/ironclaw_assistant/AGENTS.md index 04b3089828d..0029d8ffe14 100644 --- a/crates/product/ironclaw_assistant/AGENTS.md +++ b/crates/product/ironclaw_assistant/AGENTS.md @@ -322,7 +322,7 @@ by file. | Sub-owner | Owns | Never contains | Items | |---|---|---|---| -| `extensions` | Extension listing, install/import/activate/remove, the setup handshake, credential status/submit, onboarding, and the lifecycle projections | Admin *configuration* of an installed extension (that is `admin-config`) and manifest parsing (that is `ironclaw_extension_registry`) | `mod extension_credentials`, `mod extension_onboarding`, `mod extension_setup_credentials`, `mod extensions`, `mod lifecycle_setup`, `mod types`, `EXTENSION_INSTALL_CAPABILITY_ID`, `EXTENSION_INSTALL_CAPABILITY`, `EXTENSION_REGISTER_HOSTED_MCP_CAPABILITY_ID`, `EXTENSION_REGISTER_HOSTED_MCP_CAPABILITY`, `EXTENSION_IMPORT_CAPABILITY_ID`, `EXTENSION_IMPORT_CAPABILITY`, `EXTENSION_ACTIVATE_CAPABILITY_ID`, `EXTENSION_ACTIVATE_CAPABILITY`, `EXTENSION_REMOVE_CAPABILITY_ID`, `EXTENSION_REMOVE_CAPABILITY`, `EXTENSION_SETUP_SUBMIT_CAPABILITY_ID`, `EXTENSION_SETUP_SUBMIT_CAPABILITY`, `StaticChannelConnectionService`, `ExtensionCredentialStatusRequest`, `ExtensionCredentialSubmitRequest`, `ExtensionCredentialSetupService`, `parse_credential_account_id`, `reborn_services/extension_credentials.rs::ExtensionCredentialReadiness`, `reborn_services/extension_credentials.rs::RequirementCredentialReadiness`, `reborn_services/extension_credentials.rs::credential_scope`, `reborn_services/extension_credentials.rs::unique_requirements`, `reborn_services/extension_credentials.rs::readiness_for_requirements`, `reborn_services/extension_credentials.rs::credential_readiness_for_requirement`, `reborn_services/extension_credentials.rs::credential_status_for_requirement`, `reborn_services/extension_credentials.rs::requirement_readiness_for_status`, `reborn_services/extension_credentials.rs::credential_status_for_requirement_strict`, `reborn_services/extension_credentials.rs::credential_status_request`, `reborn_services/extension_credentials.rs::provider_for_requirement`, `reborn_services/extension_credentials.rs::provider_scopes_for_requirement`, `reborn_services/extension_credentials.rs::is_retryable_status_failure`, `reborn_services/extension_credentials.rs::warn_retryable_status_failure`, `reborn_services/extension_onboarding.rs::ExtensionOnboarding`, `reborn_services/extension_onboarding.rs::for_installed`, `reborn_services/extension_onboarding.rs::for_installed_with_credential_status`, `reborn_services/extension_onboarding.rs::from_lifecycle`, `reborn_services/extension_onboarding.rs::for_summary`, `reborn_services/extension_onboarding.rs::credential_onboarding`, `reborn_services/extension_onboarding.rs::no_credential_onboarding`, `reborn_services/extension_onboarding.rs::activation_instructions`, `reborn_services/extension_onboarding.rs::instructions`, `reborn_services/extension_onboarding.rs::setup_url`, `reborn_services/extension_onboarding.rs::credential_next_step`, `reborn_services/extension_setup_credentials.rs::requirements`, `reborn_services/extension_setup_credentials.rs::project`, `reborn_services/extension_setup_credentials.rs::parse_submit_payload`, `reborn_services/extension_setup_credentials.rs::submit_manual_tokens`, `reborn_services/extension_setup_credentials.rs::submit_manual_token_requirement`, `reborn_services/extension_setup_credentials.rs::setup_projection`, `reborn_services/extension_setup_credentials.rs::credential_prompt`, `reborn_services/extension_setup_credentials.rs::credential_label`, `reborn_services/extension_setup_credentials.rs::SetupSubmitPayload`, `reborn_services/extensions.rs::EXTENSION_READINESS_CONCURRENCY`, `reborn_services/extensions.rs::EXTENSIONS_VIEW`, `reborn_services/extensions.rs::EXTENSION_REGISTRY_VIEW`, `reborn_services/extensions.rs::list_extensions`, `reborn_services/extensions.rs::list_extension_registry`, `reborn_services/extensions.rs::import_extension_capability`, `reborn_services/extensions.rs::execute_lifecycle`, `reborn_services/extensions.rs::lifecycle_surface_context`, `reborn_services/extensions.rs::lifecycle_installed_extensions`, `reborn_services/extensions.rs::lifecycle_extension_infos`, `reborn_services/extensions.rs::registry_entry`, `reborn_services/extensions.rs::credential_readiness_for_extension`, `reborn_services/extensions.rs::extension_info`, `reborn_services/extensions.rs::wire_surfaces`, `reborn_services/extensions.rs::has_external_channel_surface`, `reborn_services/extensions.rs::channel_requires_personal_account`, `reborn_services/extensions.rs::channel_requires_personal_binding`, `reborn_services/extensions.rs::caller_public_state`, `reborn_services/extensions.rs::channel_auth_vendor`, `reborn_services/extensions.rs::vendor_auth_accounts`, `reborn_services/extensions.rs::projected_channel_account`, `reborn_services/lifecycle_setup.rs::EXTENSION_SETUP_VIEW`, `reborn_services/lifecycle_setup.rs::SetupAction`, `reborn_services/lifecycle_setup.rs::setup_extension_view`, `reborn_services/lifecycle_setup.rs::submit_extension_setup_capability`, `reborn_services/lifecycle_setup.rs::setup_extension`, `reborn_services/lifecycle_setup.rs::project_package`, `reborn_services/lifecycle_setup.rs::project_package_after_mutation`, `reborn_services/lifecycle_setup.rs::channel_field_status`, `reborn_services/lifecycle_setup.rs::route_channel_config_values`, `reborn_services/lifecycle_setup.rs::setup_extension_response`, `reborn_services/lifecycle_setup.rs::setup_public_phase`, `reborn_services/lifecycle_setup.rs::setup_action`, `reborn_services/lifecycle_setup.rs::parse_hosted_mcp_auth_selection`, `reborn_services/lifecycle_setup.rs::validation_error`, `reborn_services/lifecycle_setup.rs::map_lifecycle_error`, `reborn_services/types.rs::RebornExtensionListResponse`, `reborn_services/types.rs::RebornVendorAuthAccounts`, `reborn_services/types.rs::RebornAuthAccount`, `reborn_services/types.rs::RebornExtensionInfo` | +| `extensions` | Extension listing, install/import/activate/remove, the setup handshake, credential status/submit, onboarding, and the lifecycle projections | Admin *configuration* of an installed extension (that is `admin-config`) and manifest parsing (that is `ironclaw_extension_registry`) | `mod extension_credentials`, `mod extension_onboarding`, `mod extension_setup_credentials`, `mod extensions`, `mod lifecycle_setup`, `mod types`, `EXTENSION_INSTALL_CAPABILITY_ID`, `EXTENSION_INSTALL_CAPABILITY`, `EXTENSION_REGISTER_HOSTED_MCP_CAPABILITY_ID`, `EXTENSION_REGISTER_HOSTED_MCP_CAPABILITY`, `EXTENSION_IMPORT_CAPABILITY_ID`, `EXTENSION_IMPORT_CAPABILITY`, `EXTENSION_ACTIVATE_CAPABILITY_ID`, `EXTENSION_ACTIVATE_CAPABILITY`, `EXTENSION_REMOVE_CAPABILITY_ID`, `EXTENSION_REMOVE_CAPABILITY`, `EXTENSION_SETUP_SUBMIT_CAPABILITY_ID`, `EXTENSION_SETUP_SUBMIT_CAPABILITY`, `StaticChannelConnectionService`, `ExtensionCredentialStatusRequest`, `ExtensionCredentialSubmitRequest`, `ExtensionCredentialSetupService`, `parse_credential_account_id`, `reborn_services/extension_credentials.rs::ExtensionCredentialReadiness`, `reborn_services/extension_credentials.rs::RequirementCredentialReadiness`, `reborn_services/extension_credentials.rs::credential_scope`, `reborn_services/extension_credentials.rs::unique_requirements`, `reborn_services/extension_credentials.rs::readiness_for_requirements`, `reborn_services/extension_credentials.rs::credential_readiness_for_requirement`, `reborn_services/extension_credentials.rs::credential_status_for_requirement`, `reborn_services/extension_credentials.rs::requirement_readiness_for_status`, `reborn_services/extension_credentials.rs::credential_status_for_requirement_strict`, `reborn_services/extension_credentials.rs::credential_status_request`, `reborn_services/extension_credentials.rs::provider_for_requirement`, `reborn_services/extension_credentials.rs::provider_scopes_for_requirement`, `reborn_services/extension_credentials.rs::is_retryable_status_failure`, `reborn_services/extension_credentials.rs::warn_retryable_status_failure`, `reborn_services/extension_onboarding.rs::ExtensionOnboarding`, `reborn_services/extension_onboarding.rs::for_installed`, `reborn_services/extension_onboarding.rs::for_installed_with_credential_status`, `reborn_services/extension_onboarding.rs::from_lifecycle`, `reborn_services/extension_onboarding.rs::for_summary`, `reborn_services/extension_onboarding.rs::credential_onboarding`, `reborn_services/extension_onboarding.rs::no_credential_onboarding`, `reborn_services/extension_onboarding.rs::activation_instructions`, `reborn_services/extension_onboarding.rs::instructions`, `reborn_services/extension_onboarding.rs::setup_url`, `reborn_services/extension_onboarding.rs::credential_next_step`, `reborn_services/extension_setup_credentials.rs::requirements`, `reborn_services/extension_setup_credentials.rs::project`, `reborn_services/extension_setup_credentials.rs::parse_submit_payload`, `reborn_services/extension_setup_credentials.rs::submit_manual_tokens`, `reborn_services/extension_setup_credentials.rs::submit_manual_token_requirement`, `reborn_services/extension_setup_credentials.rs::setup_projection`, `reborn_services/extension_setup_credentials.rs::credential_prompt`, `reborn_services/extension_setup_credentials.rs::credential_label`, `reborn_services/extension_setup_credentials.rs::SetupSubmitPayload`, `reborn_services/extensions.rs::EXTENSION_READINESS_CONCURRENCY`, `reborn_services/extensions.rs::EXTENSIONS_VIEW`, `reborn_services/extensions.rs::EXTENSION_REGISTRY_VIEW`, `reborn_services/extensions.rs::list_extensions`, `reborn_services/extensions.rs::list_extension_registry`, `reborn_services/extensions.rs::import_extension_capability`, `reborn_services/extensions.rs::execute_lifecycle`, `reborn_services/extensions.rs::lifecycle_surface_context`, `reborn_services/extensions.rs::lifecycle_installed_extensions`, `reborn_services/extensions.rs::lifecycle_extension_infos`, `reborn_services/extensions.rs::registry_entry`, `reborn_services/extensions.rs::is_builtin_host_surface`, `reborn_services/extensions.rs::credential_readiness_for_extension`, `reborn_services/extensions.rs::extension_info`, `reborn_services/extensions.rs::wire_surfaces`, `reborn_services/extensions.rs::has_external_channel_surface`, `reborn_services/extensions.rs::channel_requires_personal_account`, `reborn_services/extensions.rs::channel_requires_personal_binding`, `reborn_services/extensions.rs::caller_public_state`, `reborn_services/extensions.rs::channel_auth_vendor`, `reborn_services/extensions.rs::vendor_auth_accounts`, `reborn_services/extensions.rs::projected_channel_account`, `reborn_services/lifecycle_setup.rs::EXTENSION_SETUP_VIEW`, `reborn_services/lifecycle_setup.rs::SetupAction`, `reborn_services/lifecycle_setup.rs::setup_extension_view`, `reborn_services/lifecycle_setup.rs::submit_extension_setup_capability`, `reborn_services/lifecycle_setup.rs::setup_extension`, `reborn_services/lifecycle_setup.rs::project_package`, `reborn_services/lifecycle_setup.rs::project_package_after_mutation`, `reborn_services/lifecycle_setup.rs::channel_field_status`, `reborn_services/lifecycle_setup.rs::route_channel_config_values`, `reborn_services/lifecycle_setup.rs::setup_extension_response`, `reborn_services/lifecycle_setup.rs::setup_public_phase`, `reborn_services/lifecycle_setup.rs::setup_action`, `reborn_services/lifecycle_setup.rs::parse_hosted_mcp_auth_selection`, `reborn_services/lifecycle_setup.rs::validation_error`, `reborn_services/lifecycle_setup.rs::map_lifecycle_error`, `reborn_services/types.rs::RebornExtensionListResponse`, `reborn_services/types.rs::RebornVendorAuthAccounts`, `reborn_services/types.rs::RebornAuthAccount`, `reborn_services/types.rs::RebornExtensionInfo` | | `admin-config` | Operator configuration keys, the global auto-approve setting, and the tool-permission model (effective state, hard floors, persistent user policy) | Operator *service* control or diagnostics — those are `operator` | `mod admin_configuration`, `mod operator_config_views`, `AUTO_APPROVE_CONFIG_KEY`, `TOOL_CONFIG_PREFIX`, `OPERATOR_CONFIG_SET_AUTO_APPROVE_CAPABILITY_ID`, `OPERATOR_CONFIG_SET_AUTO_APPROVE_CAPABILITY`, `OPERATOR_CONFIG_SET_TOOL_PERMISSION_CAPABILITY_ID`, `OPERATOR_CONFIG_SET_TOOL_PERMISSION_CAPABILITY`, `OPERATOR_CONFIG_SET_KEY_COMMAND_ID`, `OPERATOR_CONFIG_SET_KEY_COMMAND`, `GLOBAL_AUTO_APPROVE_VIEW`, `RebornOperatorApprovalConfig`, `operator_config_not_wired_response`, `operator_config_unknown_key_error`, `operator_config_invalid_value`, `operator_config_store_error`, `operator_config_capability_forbidden`, `product_view_requires_operator_config`, `operator_config_auto_approve_activity_id`, `operator_config_mutation_succeeded`, `auto_approve_config_entry`, `find_operator_tool`, `tool_config_entry`, `tool_config_entry_with_context`, `OperatorToolPermissionContext`, `operator_tool_permission_context`, `effective_tool_permission`, `persistent_user_policy_active`, `persistent_user_policy_key`, `operator_tool_permission_scope`, `tool_permission_locked`, `hard_floor_tool`, `default_tool_permission_state`, `tool_permission_state_wire`, `ToolPermissionUpdate`, `parse_tool_permission_state`, `apply_tool_permission_state`, `operator_config_surface_not_wired_diagnostic`, `operator_config_validation_diagnostics`, `operator_config_key_diagnostic`, `reborn_services/admin_configuration.rs::ADMIN_CONFIGURATION_VIEW`, `reborn_services/admin_configuration.rs::ADMIN_CONFIGURATION_REPLACE_CAPABILITY_ID`, `reborn_services/admin_configuration.rs::ADMIN_CONFIGURATION_REPLACE_CAPABILITY`, `reborn_services/admin_configuration.rs::RebornAdminConfigurationListResponse`, `reborn_services/admin_configuration.rs::RebornAdminConfigurationGroup`, `reborn_services/admin_configuration.rs::RebornAdminConfigurationField`, `reborn_services/admin_configuration.rs::RebornAdminConfigurationUse`, `reborn_services/operator_config_views.rs::OPERATOR_CONFIG_LIST_VIEW`, `reborn_services/operator_config_views.rs::OPERATOR_CONFIG_KEY_VIEW`, `reborn_services/operator_config_views.rs::OPERATOR_CONFIG_VALIDATE_VIEW`, `reborn_services/operator_config_views.rs::OperatorConfigKeyViewParams` | | `operator` | Operator status, logs, service lifecycle, first-run setup validation, and the doctor/diagnostics projections | Configuration values — those are `admin-config` | `mod log_views`, `mod operator_command_views`, `OPERATOR_SETUP_RUN_CAPABILITY_ID`, `OPERATOR_SETUP_RUN_CAPABILITY`, `OPERATOR_SERVICE_LIFECYCLE_COMMAND_ID`, `OPERATOR_SERVICE_LIFECYCLE_COMMAND`, `OPERATOR_LOGS_DEFAULT_LIMIT`, `OPERATOR_LOGS_MAX_LIMIT`, `OPERATOR_LOGS_CURSOR_MAX_BYTES`, `OPERATOR_LOGS_TARGET_MAX_BYTES`, `StaticOperatorStatusService`, `UnsupportedOperatorStatusService`, `UnsupportedOperatorLogsService`, `UnsupportedOperatorServiceLifecycleService`, `operator_setup_validation_error`, `operator_setup_diagnostic`, `OPERATOR_SETUP_PROFILE_ID_MAX_BYTES`, `OPERATOR_SETUP_WEBUI_TOKEN_MIN_BYTES`, `OPERATOR_SETUP_WEBUI_TOKEN_MAX_BYTES`, `OPERATOR_SETUP_REDACTED_SECRET_SENTINEL`, `validate_operator_setup_profile_id`, `validate_operator_setup_webui_access_token`, `reject_unwired_operator_setup_host_mutation`, `OperatorSetupHostState`, `setup_response_from_llm_snapshot`, `operator_doctor_status_diagnostic`, `operator_doctor_status_response`, `operator_doctor_status_check`, `operator_doctor_status_reason_code`, `is_operator_doctor_reason_code_component`, `operator_doctor_status_text`, `operator_doctor_status_text_needs_redaction`, `operator_doctor_setup_unavailable_diagnostic`, `operator_doctor_status_unavailable_diagnostic`, `operator_diagnostics_surface_status`, `operator_surface_unavailable`, `validate_log_query_modes`, `bounded_operator_logs_query`, `bounded_log_query`, `bounded_operator_logs_string`, `bounded_operator_logs_context_string`, `reborn_services/log_views.rs::LOGS_VIEW`, `reborn_services/log_views.rs::OPERATOR_LOGS_VIEW`, `reborn_services/operator_command_views.rs::OPERATOR_DIAGNOSTICS_VIEW`, `reborn_services/operator_command_views.rs::OPERATOR_STATUS_VIEW`, `reborn_services/operator_command_views.rs::OPERATOR_SETUP_VIEW` | | `inspector` | Operator-only reads of bounded, process-local run diagnostics and their resumable update batches | Normal product projections, durable events, or diagnostic capture policy — this owner only authorizes and reads the shared store | `mod inspector`, `reborn_services/inspector.rs::diagnostic_scope`, `reborn_services/inspector.rs::store_error`, `reborn_services/inspector.rs::snapshot`, `reborn_services/inspector.rs::prompt`, `reborn_services/inspector.rs::tool`, `reborn_services/inspector.rs::updates` | diff --git a/crates/product/ironclaw_assistant/src/reborn_services/extensions.rs b/crates/product/ironclaw_assistant/src/reborn_services/extensions.rs index 373497384ee..21339f8e883 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/extensions.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/extensions.rs @@ -59,7 +59,14 @@ pub(super) async fn list_extensions( LifecycleProductAction::ExtensionList, ) .await?; - let installed = lifecycle_installed_extensions(&lifecycle); + // Host-managed channels (outbound-only, no connect affordance — the web + // UI's browser push) are infrastructure, not browse-and-install + // extensions: keep them out of the install UI while they stay working + // notification channels. See `is_host_managed_channel`. + let installed = lifecycle_installed_extensions(&lifecycle) + .into_iter() + .filter(|extension| !is_builtin_host_surface(&extension.summary)) + .collect::>(); let connections = channel_connection_service .caller_channel_connections(caller.clone()) .await?; @@ -119,6 +126,7 @@ pub(super) async fn list_extension_registry( Ok(RebornExtensionRegistryResponse { entries: registry_entries .iter() + .filter(|extension| !is_builtin_host_surface(&extension.summary)) .cloned() .map(|extension| registry_entry(extension.summary, &installed_ids)) .collect(), @@ -217,6 +225,17 @@ async fn lifecycle_extension_infos( .collect()) } +/// The host's own built-in surface — always present, not a browse-and-install +/// integration — is hidden from the install catalog even though it backs a +/// channel (the web UI's browser-push channel). This is an explicit id +/// classification rather than one inferred from channel direction, so it stays +/// correct as the web-app channel later gains inbound/outbound capabilities. +/// Naming the package dir here is allowed by `NON_VENDOR_PROVIDER_PACKAGE_DIRS` +/// (reborn_extension_specificity). +fn is_builtin_host_surface(summary: &LifecycleExtensionSummary) -> bool { + matches!(summary.package_ref.id.as_str(), "web-push") +} + fn registry_entry( summary: LifecycleExtensionSummary, installed_ids: &HashSet, diff --git a/crates/product/ironclaw_webui/frontend/public/assets/site.webmanifest b/crates/product/ironclaw_webui/frontend/public/assets/site.webmanifest index 2b37b84489c..69ab8803b0b 100644 --- a/crates/product/ironclaw_webui/frontend/public/assets/site.webmanifest +++ b/crates/product/ironclaw_webui/frontend/public/assets/site.webmanifest @@ -9,13 +9,13 @@ "src": "/assets/web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", - "purpose": "maskable" + "purpose": "any maskable" }, { "src": "/assets/web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", - "purpose": "maskable" + "purpose": "any maskable" } ], "theme_color": "#3571be", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts index c9cc61f3e9b..825cb71916d 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ar.ts @@ -794,7 +794,7 @@ registerPack("ar", { "automations.notificationChannels.explainer": "اختر القنوات المتصلة التي تتلقى طلبات الموافقة وطلبات المصادقة وإشعارات فشل التشغيل.", "automations.notificationChannels.empty": "لا توجد قنوات متصلة بعد.", - "automations.notificationChannels.webOnlyHelper": "لم يتم اختيار أي قناة إشعارات — لن يتم تسليم طلبات الموافقة وتنبيهات المصادقة وإشعارات الفشل إلى أي وجهة.", + "automations.notificationChannels.noSelectionHelper": "لم يتم اختيار أي قناة إشعارات — لن يتم تسليم طلبات الموافقة وتنبيهات المصادقة وإشعارات الفشل إلى أي وجهة.", "automations.notificationChannels.webPush.deviceHeading": "هذا المتصفح", "automations.notificationChannels.webPush.checking": "جارٍ التحقق من دعم الإشعارات في هذا المتصفح…", "automations.notificationChannels.webPush.unsupported": "إشعارات الدفع غير متاحة في هذا المتصفح.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/de.ts b/crates/product/ironclaw_webui/frontend/src/i18n/de.ts index dcabe8c1811..834d4890364 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/de.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/de.ts @@ -794,7 +794,7 @@ registerPack("de", { "automations.notificationChannels.explainer": "Wähle, welche verbundenen Kanäle Freigabe-Anfragen, Anmelde-Aufforderungen und Hinweise zu fehlgeschlagenen Läufen erhalten.", "automations.notificationChannels.empty": "Noch keine verbundenen Kanäle.", - "automations.notificationChannels.webOnlyHelper": "Kein Benachrichtigungskanal ausgewählt — Freigabe-Anfragen, Anmelde-Aufforderungen und Fehlermeldungen werden nirgendwohin zugestellt.", + "automations.notificationChannels.noSelectionHelper": "Kein Benachrichtigungskanal ausgewählt — Freigabe-Anfragen, Anmelde-Aufforderungen und Fehlermeldungen werden nirgendwohin zugestellt.", "automations.notificationChannels.webPush.deviceHeading": "Dieser Browser", "automations.notificationChannels.webPush.checking": "Benachrichtigungsunterstützung dieses Browsers wird geprüft…", "automations.notificationChannels.webPush.unsupported": "Push-Benachrichtigungen sind in diesem Browser nicht verfügbar.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/en.ts b/crates/product/ironclaw_webui/frontend/src/i18n/en.ts index fd9973b4912..e2ee4867178 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/en.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/en.ts @@ -843,7 +843,7 @@ registerPack("en", { "automations.notificationChannels.explainer": "Choose which connected channels receive approval prompts, auth prompts, and run-failure notices.", "automations.notificationChannels.empty": "No connected channels yet.", - "automations.notificationChannels.webOnlyHelper": "No notification channel is selected — approval prompts, auth prompts, and failure notices won't be delivered anywhere.", + "automations.notificationChannels.noSelectionHelper": "No notification channel is selected — approval prompts, auth prompts, and failure notices won't be delivered anywhere.", "automations.notificationChannels.webPush.deviceHeading": "This browser", "automations.notificationChannels.webPush.checking": "Checking this browser's notification support…", "automations.notificationChannels.webPush.unsupported": "Push notifications aren't available in this browser.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/es.ts b/crates/product/ironclaw_webui/frontend/src/i18n/es.ts index 6ae0f17528a..cb180e71fc3 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/es.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/es.ts @@ -794,7 +794,7 @@ registerPack("es", { "automations.notificationChannels.explainer": "Elige qué canales conectados reciben solicitudes de aprobación, solicitudes de autenticación y avisos de ejecuciones fallidas.", "automations.notificationChannels.empty": "Aún no hay canales conectados.", - "automations.notificationChannels.webOnlyHelper": "No hay ningún canal de notificaciones seleccionado: las solicitudes de aprobación, los avisos de autenticación y los fallos no se entregarán a ningún destino.", + "automations.notificationChannels.noSelectionHelper": "No hay ningún canal de notificaciones seleccionado: las solicitudes de aprobación, los avisos de autenticación y los fallos no se entregarán a ningún destino.", "automations.notificationChannels.webPush.deviceHeading": "Este navegador", "automations.notificationChannels.webPush.checking": "Comprobando la compatibilidad de notificaciones de este navegador…", "automations.notificationChannels.webPush.unsupported": "Las notificaciones push no están disponibles en este navegador.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts b/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts index 9504b24bd76..34d6134e977 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/fr.ts @@ -794,7 +794,7 @@ registerPack("fr", { "automations.notificationChannels.explainer": "Choisissez quels canaux connectés reçoivent les demandes d'approbation, les invites d'authentification et les avis d'échec d'exécution.", "automations.notificationChannels.empty": "Aucun canal connecté pour le moment.", - "automations.notificationChannels.webOnlyHelper": "Aucun canal de notification n'est sélectionné — les demandes d'approbation, les invites d'authentification et les avis d'échec ne seront livrés nulle part.", + "automations.notificationChannels.noSelectionHelper": "Aucun canal de notification n'est sélectionné — les demandes d'approbation, les invites d'authentification et les avis d'échec ne seront livrés nulle part.", "automations.notificationChannels.webPush.deviceHeading": "Ce navigateur", "automations.notificationChannels.webPush.checking": "Vérification de la prise en charge des notifications par ce navigateur…", "automations.notificationChannels.webPush.unsupported": "Les notifications push ne sont pas disponibles dans ce navigateur.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts b/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts index 19820af3d31..a516bf7b20a 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/hi.ts @@ -794,7 +794,7 @@ registerPack("hi", { "automations.notificationChannels.explainer": "चुनें कि कौन से कनेक्टेड चैनल अनुमोदन अनुरोध, प्रमाणीकरण अनुरोध और रन विफलता की सूचनाएँ प्राप्त करें।", "automations.notificationChannels.empty": "अभी कोई कनेक्टेड चैनल नहीं है।", - "automations.notificationChannels.webOnlyHelper": "कोई सूचना चैनल चयनित नहीं है — अनुमोदन अनुरोध, प्रमाणीकरण संकेत और विफलता सूचनाएँ कहीं नहीं पहुँचाई जाएँगी।", + "automations.notificationChannels.noSelectionHelper": "कोई सूचना चैनल चयनित नहीं है — अनुमोदन अनुरोध, प्रमाणीकरण संकेत और विफलता सूचनाएँ कहीं नहीं पहुँचाई जाएँगी।", "automations.notificationChannels.webPush.deviceHeading": "यह ब्राउज़र", "automations.notificationChannels.webPush.checking": "इस ब्राउज़र में सूचना समर्थन की जाँच हो रही है…", "automations.notificationChannels.webPush.unsupported": "इस ब्राउज़र में पुश सूचनाएँ उपलब्ध नहीं हैं।", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts index 279a2477054..faa47b574fb 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ja.ts @@ -794,7 +794,7 @@ registerPack("ja", { "automations.notificationChannels.explainer": "承認リクエスト、認証リクエスト、実行失敗の通知を受け取る接続済みチャネルを選択します。", "automations.notificationChannels.empty": "接続済みのチャネルはまだありません。", - "automations.notificationChannels.webOnlyHelper": "通知チャネルが選択されていません — 承認リクエスト、認証プロンプト、失敗通知はどこにも配信されません。", + "automations.notificationChannels.noSelectionHelper": "通知チャネルが選択されていません — 承認リクエスト、認証プロンプト、失敗通知はどこにも配信されません。", "automations.notificationChannels.webPush.deviceHeading": "このブラウザ", "automations.notificationChannels.webPush.checking": "このブラウザの通知サポートを確認しています…", "automations.notificationChannels.webPush.unsupported": "このブラウザではプッシュ通知を利用できません。", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts b/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts index 1ad10dc338b..ae2852164a4 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/ko.ts @@ -794,7 +794,7 @@ registerPack("ko", { "automations.notificationChannels.explainer": "승인 요청, 인증 요청, 실행 실패 알림을 받을 연결된 채널을 선택하세요.", "automations.notificationChannels.empty": "아직 연결된 채널이 없습니다.", - "automations.notificationChannels.webOnlyHelper": "선택된 알림 채널이 없습니다 — 승인 요청, 인증 안내, 실패 알림이 어디에도 전달되지 않습니다.", + "automations.notificationChannels.noSelectionHelper": "선택된 알림 채널이 없습니다 — 승인 요청, 인증 안내, 실패 알림이 어디에도 전달되지 않습니다.", "automations.notificationChannels.webPush.deviceHeading": "이 브라우저", "automations.notificationChannels.webPush.checking": "이 브라우저의 알림 지원을 확인하는 중…", "automations.notificationChannels.webPush.unsupported": "이 브라우저에서는 푸시 알림을 사용할 수 없습니다.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts b/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts index e77a1c9618a..a4cd8688194 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/pt-BR.ts @@ -794,7 +794,7 @@ registerPack("pt-BR", { "automations.notificationChannels.explainer": "Escolha quais canais conectados recebem pedidos de aprovação, solicitações de autenticação e avisos de falha de execução.", "automations.notificationChannels.empty": "Ainda não há canais conectados.", - "automations.notificationChannels.webOnlyHelper": "Nenhum canal de notificação está selecionado — solicitações de aprovação, avisos de autenticação e notificações de falha não serão entregues em lugar algum.", + "automations.notificationChannels.noSelectionHelper": "Nenhum canal de notificação está selecionado — solicitações de aprovação, avisos de autenticação e notificações de falha não serão entregues em lugar algum.", "automations.notificationChannels.webPush.deviceHeading": "Este navegador", "automations.notificationChannels.webPush.checking": "Verificando o suporte a notificações deste navegador…", "automations.notificationChannels.webPush.unsupported": "Notificações push não estão disponíveis neste navegador.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts b/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts index e6a0cd07947..0de55222f4b 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/uk.ts @@ -794,7 +794,7 @@ registerPack("uk", { "automations.notificationChannels.explainer": "Виберіть, які підключені канали отримуватимуть запити на схвалення, запити автентифікації та сповіщення про невдалі запуски.", "automations.notificationChannels.empty": "Підключених каналів поки немає.", - "automations.notificationChannels.webOnlyHelper": "Канал сповіщень не вибрано — запити на схвалення, підказки автентифікації та сповіщення про збої нікуди не доставлятимуться.", + "automations.notificationChannels.noSelectionHelper": "Канал сповіщень не вибрано — запити на схвалення, підказки автентифікації та сповіщення про збої нікуди не доставлятимуться.", "automations.notificationChannels.webPush.deviceHeading": "Цей браузер", "automations.notificationChannels.webPush.checking": "Перевіряємо підтримку сповіщень у цьому браузері…", "automations.notificationChannels.webPush.unsupported": "Push-сповіщення недоступні в цьому браузері.", diff --git a/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts b/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts index 1e85b53433d..1c009138f0d 100644 --- a/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts +++ b/crates/product/ironclaw_webui/frontend/src/i18n/zh-CN.ts @@ -793,7 +793,7 @@ registerPack("zh-CN", { "automations.notificationChannels.explainer": "选择哪些已连接的渠道接收审批请求、认证请求和运行失败通知。", "automations.notificationChannels.empty": "暂无已连接的渠道。", - "automations.notificationChannels.webOnlyHelper": "未选择任何通知渠道——审批请求、认证提示和失败通知将不会送达任何地方。", + "automations.notificationChannels.noSelectionHelper": "未选择任何通知渠道——审批请求、认证提示和失败通知将不会送达任何地方。", "automations.notificationChannels.webPush.deviceHeading": "此浏览器", "automations.notificationChannels.webPush.checking": "正在检查此浏览器的通知支持…", "automations.notificationChannels.webPush.unsupported": "此浏览器无法使用推送通知。", diff --git a/crates/product/ironclaw_webui/frontend/src/lib/api.test.ts b/crates/product/ironclaw_webui/frontend/src/lib/api.test.ts index 9d01c1489e2..8218f2bc977 100644 --- a/crates/product/ironclaw_webui/frontend/src/lib/api.test.ts +++ b/crates/product/ironclaw_webui/frontend/src/lib/api.test.ts @@ -405,8 +405,8 @@ test("setNotificationChannels sends an explicit empty array as the intentional c }; // An explicit `[]` stays the one supported way to clear the set (spec §7: - // "an empty list means notifications stay in the web app only") — the - // guard above rejects only the *absent* argument. + // an empty list clears every notification channel) — the guard above + // rejects only the *absent* argument. await setNotificationChannels({ targetIds: [] }); assert.equal(calls.length, 1); diff --git a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts index 1357893fa2f..cdf08267d4b 100644 --- a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts +++ b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.test.ts @@ -12,7 +12,7 @@ const COPY = { "automations.notificationChannels.explainer": "Choose which connected channels receive approval prompts, auth prompts, and run-failure notices.", "automations.notificationChannels.empty": "No connected channels yet.", - "automations.notificationChannels.webOnlyHelper": + "automations.notificationChannels.noSelectionHelper": "No notification channel is selected — approval prompts, auth prompts, and failure notices won't be delivered anywhere.", "automations.notificationChannels.webPush.deviceHeading": "This browser", "automations.notificationChannels.webPush.checking": @@ -65,7 +65,7 @@ function sourceForTest() { } lines.push(line.replace(/^export function /, "function ")); } - return `${lines.join("\n")}\nglobalThis.__testExports = { NotificationChannelsPanel, WebPushDeviceBlock };`; + return `${lines.join("\n")}\nglobalThis.__testExports = { NotificationChannelsPanel, WebPushDeviceBlock, WebPushDeviceRow };`; } function html(strings, ...values) { @@ -427,7 +427,7 @@ test("NotificationChannelsPanel Save posts the full-replace target_ids array", ( ); }); -test("NotificationChannelsPanel shows the web-app-only helper text once every channel is unchecked", () => { +test("NotificationChannelsPanel shows the no-channel-selected helper text once every channel is unchecked", () => { const harness = createHarness(); let rendered = harness.render({ targets: [target("slack-alpha")], @@ -663,27 +663,41 @@ function fakeDevice(overrides = {}) { // block is MOUNTED with the right props (and only under the web-push row), // and the block's own rendering is asserted by calling it directly. -test("NotificationChannelsPanel mounts the device block under the web-push row only", () => { +test("NotificationChannelsPanel mounts the device row under the web-push row only", () => { const harness = createHarness(); const withWebPush = harness.render({ targets: [target("slack-alpha"), webPushTarget()], channels: [], }); - const blocks = componentProps(withWebPush, harness.exports.WebPushDeviceBlock); - assert.equal(blocks.length, 1, "exactly one device block for the web-push row"); - assert.ok(blocks[0].device, "the block receives the device state"); + assert.equal( + componentProps(withWebPush, harness.exports.WebPushDeviceRow).length, + 1, + "exactly one device row for the web-push row", + ); const withoutWebPush = harness.render({ targets: [target("slack-alpha"), target("slack-beta")], channels: [], }); assert.equal( - componentProps(withoutWebPush, harness.exports.WebPushDeviceBlock).length, + componentProps(withoutWebPush, harness.exports.WebPushDeviceRow).length, 0, - "no device block without a web-push row", + "no device row without a web-push row — the status query never mounts for users without web push", ); }); +test("WebPushDeviceRow wires the device hook into the block", () => { + // The row is the only place the web-push device hook runs; the block itself + // stays a pure view. Prove the wrapper passes the hook's state through, so + // moving the hook off the panel top didn't sever the block from its data. + const device = fakeDevice({ browser: { state: "enrolled", accountMatch: true } }); + const harness = createHarness({ webPushDevice: device }); + const rendered = harness.exports.WebPushDeviceRow(); + const [block] = componentProps(rendered, harness.exports.WebPushDeviceBlock); + assert.ok(block, "the row renders the device block"); + assert.equal(block.device, device, "the block receives the hook's device state"); +}); + test("NotificationChannelsPanel toggles the web-push target into the full-replace save set like any channel", () => { const saved = []; const harness = createHarness({ diff --git a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx index 2c49097716d..0a7ec3f5abc 100644 --- a/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx +++ b/crates/product/ironclaw_webui/frontend/src/pages/automations/components/notification-channels-panel.tsx @@ -63,7 +63,7 @@ function WebPushDeviceBlock({ device, t }) { const canUnenroll = state === "enrolled" && !device.isBusy; return (
{t("automations.notificationChannels.webPush.deviceHeading")} @@ -121,9 +121,23 @@ function WebPushDeviceBlock({ device, t }) { ); } +/** + * Thin wrapper that owns the web-push device hook so it runs ONLY when a + * web-push row is present (it is mounted for that row alone in + * NotificationChannelsPanel). Keeping the hook here — instead of at the panel + * top, where it fired the account status query and per-browser probe for every + * automations view, including users and deployments with no web-push channel — + * scopes that work to the one row that uses it. `WebPushDeviceBlock` stays a + * pure, prop-driven view. + */ +function WebPushDeviceRow() { + const t = useT(); + const device = useWebPushDevice(); + return ; +} + export function NotificationChannelsPanel({ channelsState }) { const t = useT(); - const webPushDevice = useWebPushDevice(); const rows = channelsState.rows; // `selectedIds` is the server truth (the caller's stored notification- // channel set, spec §7); `draftIds` is the staged, locally-toggled copy @@ -304,8 +318,7 @@ export function NotificationChannelsPanel({ channelsState }) { return (
{rowLabel} - {isWebPushRow && - ()} + {isWebPushRow && }
); })} @@ -357,14 +370,14 @@ export function NotificationChannelsPanel({ channelsState }) { {/* ── Empty-selection helper (draft, not stored — reflects what the next Save would do). Suppressed after a failed read: the draft is then only an artifact of the channels the GET never - returned, so "notifications stay in the web app" would be a + returned, so asserting that nothing is selected would be a claim about state the panel does not actually know. ──────── */} {!hasLoadError && draftIds.size === 0 && (
- {t("automations.notificationChannels.webOnlyHelper")} + {t("automations.notificationChannels.noSelectionHelper")}
)} diff --git a/tests/integration/webui_v2_product_api.rs b/tests/integration/webui_v2_product_api.rs index 6aeb35d6118..2f7ce2ea825 100644 --- a/tests/integration/webui_v2_product_api.rs +++ b/tests/integration/webui_v2_product_api.rs @@ -2512,6 +2512,30 @@ async fn web_push_enrollment_and_notification_channel_round_trip_through_product "{body}" ); + // web-push is host infrastructure, not a browse-and-install extension: it + // must NOT appear in the install catalog (registry or installed lists), + // even though it stays a selectable outbound notification target (above). + let (status, body) = get_json(router(), "/api/webchat/v2/extensions/registry").await; + assert_eq!(status, StatusCode::OK, "registry response: {body}"); + assert!( + !body["entries"] + .as_array() + .expect("registry entries array") + .iter() + .any(|entry| entry["package_ref"]["id"] == "web-push"), + "web-push must be hidden from the install registry: {body}" + ); + let (status, body) = get_json(router(), "/api/webchat/v2/extensions").await; + assert_eq!(status, StatusCode::OK, "extensions response: {body}"); + assert!( + !body["extensions"] + .as_array() + .expect("installed extensions array") + .iter() + .any(|entry| entry["package_ref"]["id"] == "web-push"), + "web-push must be hidden from the installed extensions list: {body}" + ); + // Enroll → enrolled; identical repeat → refreshed. let subscription = browser_subscription_body(ENDPOINT); let (status, body) = post_json( From 1b8eada79879238e8843308a2cbb47f72a8d900d Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sun, 9 Aug 2026 13:36:55 -0400 Subject: [PATCH 07/13] docs(web-push): fix stale helper name in install-catalog filter comment CodeRabbit flagged that the filter comment still named the removed `is_host_managed_channel` helper and described the old outbound-only heuristic. Point it at `is_builtin_host_surface` and the id-based classification the code actually uses. Co-Authored-By: Claude Opus 4.8 --- .../ironclaw_assistant/src/reborn_services/extensions.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/product/ironclaw_assistant/src/reborn_services/extensions.rs b/crates/product/ironclaw_assistant/src/reborn_services/extensions.rs index 21339f8e883..8f30fe516f2 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/extensions.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/extensions.rs @@ -59,10 +59,10 @@ pub(super) async fn list_extensions( LifecycleProductAction::ExtensionList, ) .await?; - // Host-managed channels (outbound-only, no connect affordance — the web - // UI's browser push) are infrastructure, not browse-and-install - // extensions: keep them out of the install UI while they stay working - // notification channels. See `is_host_managed_channel`. + // The web UI's browser-push channel is host infrastructure, not a + // browse-and-install integration, so keep it out of the install UI while it + // stays a working notification channel. Classified by id (see + // `is_builtin_host_surface`), not by channel direction. let installed = lifecycle_installed_extensions(&lifecycle) .into_iter() .filter(|extension| !is_builtin_host_surface(&extension.summary)) From b85f2707defcbeb1341d0a4ae85a0d37a532899d Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sun, 9 Aug 2026 14:01:25 -0400 Subject: [PATCH 08/13] test(web-push): pin notification-helper i18n key as noSelectionHelper The P1 rename webOnlyHelper -> noSelectionHelper missed two test assertions that pin the i18n key, failing the affected-3 Reborn crate bucket (Tests (Reborn) rolls it up): - crates/app/ironclaw_composition/tests/webui_v2_serve.rs (served bundle) - crates/product/ironclaw_webui/src/webui_v2/static_assets/assets.rs (live source) Also renames the now-misleading test fn and corrects a stale bundle-budget comment. Test-only; no production behavior change. Co-Authored-By: Claude Opus 4.8 --- crates/app/ironclaw_composition/tests/webui_v2_serve.rs | 6 +++--- .../ironclaw_webui/frontend/scripts/check-bundle-budgets.ts | 2 +- .../ironclaw_webui/src/webui_v2/static_assets/assets.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/app/ironclaw_composition/tests/webui_v2_serve.rs b/crates/app/ironclaw_composition/tests/webui_v2_serve.rs index 4dfce210d89..3784644113c 100644 --- a/crates/app/ironclaw_composition/tests/webui_v2_serve.rs +++ b/crates/app/ironclaw_composition/tests/webui_v2_serve.rs @@ -3507,7 +3507,7 @@ async fn static_automations_run_row_spaces_action_button_icons() { } #[tokio::test] -async fn static_automations_notification_channels_surface_save_error_and_web_only_helper() { +async fn static_automations_notification_channels_surface_save_error_and_no_selection_helper() { let body = served_bundled_javascript().await; // Was `e.saveError&&!a` — a minifier-assigned identifier pin that breaks on @@ -3515,7 +3515,7 @@ async fn static_automations_notification_channels_surface_save_error_and_web_onl // rendered through `t("automations.notificationChannels.saveFailed")` // (`notification-channels-panel.tsx`); the i18n key is a string literal, so // it survives minification — pin that instead, mirroring the retargeted - // Task-11 `webOnlyHelper` pin below. + // Task-11 `noSelectionHelper` pin below. assert!( body.contains("automations.notificationChannels.saveFailed"), "the notification-channels panel must render the save error instead of swallowing it" @@ -3526,7 +3526,7 @@ async fn static_automations_notification_channels_surface_save_error_and_web_onl // conditional footer is the empty-selection helper — pin that instead. The // i18n key is a string literal, so it survives minification. assert!( - body.contains("automations.notificationChannels.webOnlyHelper"), + body.contains("automations.notificationChannels.noSelectionHelper"), "the empty-selection helper must be rendered when no channel is selected" ); } diff --git a/crates/product/ironclaw_webui/frontend/scripts/check-bundle-budgets.ts b/crates/product/ironclaw_webui/frontend/scripts/check-bundle-budgets.ts index f4f4fd84756..eef842fe01b 100644 --- a/crates/product/ironclaw_webui/frontend/scripts/check-bundle-budgets.ts +++ b/crates/product/ironclaw_webui/frontend/scripts/check-bundle-budgets.ts @@ -52,7 +52,7 @@ const LOGIN_GZIP_BUDGET = 180_000; // from becoming trusted. The measured /chat closure is 215.8 KB gzip; 217.0 KB // retains about 1.2 KB of explicit headroom without weakening the feature. // Web Push notifications then added ~13 `automations.notificationChannels.webPush.*` -// keys plus a reworded `webOnlyHelper` to the eager `en.ts` fallback pack. The +// keys plus a reworded empty-selection helper (`noSelectionHelper`) to the eager `en.ts` fallback pack. The // eager-code weight was kept OUT of /chat: `registerServiceWorker` lives in the // dependency-free `lib/register-sw.ts` (so boot does not pull the enrollment // lib's api + WebCrypto imports), and the enrollment UI rides the already-lazy diff --git a/crates/product/ironclaw_webui/src/webui_v2/static_assets/assets.rs b/crates/product/ironclaw_webui/src/webui_v2/static_assets/assets.rs index 97f5087d879..82008263ddf 100644 --- a/crates/product/ironclaw_webui/src/webui_v2/static_assets/assets.rs +++ b/crates/product/ironclaw_webui/src/webui_v2/static_assets/assets.rs @@ -507,7 +507,7 @@ mod tests { source_text("pages/automations/components/notification-channels-panel.tsx"); assert!(channels_panel.contains(r#"type="checkbox""#)); assert!(channels_panel.contains("saveNotificationChannels")); - assert!(channels_panel.contains("automations.notificationChannels.webOnlyHelper")); + assert!(channels_panel.contains("automations.notificationChannels.noSelectionHelper")); // Badge label must branch on row status — an unavailable (stored but // no-longer-resolving) channel must not display the "ready" label. assert!( From 9daee7f0a90227176046747bff468d49f50eaebc Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sun, 9 Aug 2026 15:02:39 -0400 Subject: [PATCH 09/13] feat(channels): notifications as a first-class channel capability Separates "notification target" from "final-reply / model-delivery target" so the web app is a notification channel (blocked-automation notices) without being a place the model or a run's final reply is delivered to. Folds the notifications-capability work into the web-push PR (#7398). Model / declarative: - ChannelDescriptor gains a `notifications` capability (serde default), so a manifest can independently declare inbound / outbound / notifications. The web-push manifest declares `notifications = true` (kept `outbound = true` for now: it is what registers the channel's delivery binding; outbound thread-creation is a later capability). Pinned by manifest_lockstep. Delivery authority (the load-bearing separation): - DeliveryTargetCapabilities gains a `notifications` bool, and a new `OutboundDeliveryTargetProvider::resolve_notification_target` resolves a target the caller may receive blocked-automation notices on, gated on `notifications` (not `final_replies`). Default impl + registry/mutable-registry overrides; the generic channel provider reuses its id-resolution. - web-push target caps flip to `final_replies: false, notifications: true`: browser push is where a run's reply already lands, never a destination the model/final-reply path delivers *to*. - The notification-channel picker (`/outbound/targets`) and the notifier + set-validation resolution now gate on the notifications capability; the model-facing delivery list (`builtin.outbound_delivery_targets_list`) narrows to `final_replies`, so a notification-only target (browser push) is invisible to the model until it gains outbound delivery. Why this shape: production blocked-automation notices run through TriggeredRunDeliveryDriver -> DeliveryCoordinator -> WebPushChannelAdapter and select targets by capability resolution; the `ThreadNotificationPolicy` / `progress` push-plan in ironclaw_outbound is dormant (no production writer or caller), so gating on `progress` would not have changed delivery. Slack and Telegram are both final-reply and notification targets and are unaffected. Regression coverage: web-push resolves as a notification target but NOT as a final-reply/model target (targets.rs); existing notifier + notification-channel integration journeys stay green. Co-Authored-By: Claude Opus 4.8 --- .../tests/reborn_dependency_boundaries.rs | 6 ++- .../app/ironclaw_composition/src/runtime.rs | 4 ++ .../src/runtime/capability_host/tests.rs | 3 ++ .../src/channel.rs | 6 +++ .../src/delivery_resolution.rs | 7 +++ .../ironclaw_outbound/src/delivery_targets.rs | 49 +++++++++++++++++++ .../src/channel_host/e2e_tests.rs | 1 + .../src/channel_outbound_targets.rs | 13 +++++ .../packages/web-push/manifest.toml | 1 + .../packages/web-push/src/targets.rs | 39 +++++++++++---- .../web-push/tests/manifest_lockstep.rs | 5 ++ .../src/model_channel_delivery/tests.rs | 1 + .../src/notification_channel_resolution.rs | 2 +- .../outbound_delivery_capability_surface.rs | 7 +++ .../reborn_services/outbound_preferences.rs | 32 +++++++++++- .../notification_channels.rs | 2 +- .../outbound_preferences/support_tests.rs | 3 ++ .../tests/run_delivery_contract.rs | 1 + 18 files changed, 169 insertions(+), 13 deletions(-) diff --git a/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs b/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs index d28b6602557..aacf38fcd60 100644 --- a/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs +++ b/crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs @@ -624,7 +624,11 @@ fn reborn_contracts_crates_carry_a_checked_size_ceiling() { // 7_748 -> 7_752 (2026-08-08, web-push channel): +4 lines for the // `VapidAuthorization` arm in the channel egress injection validator // — schema vocabulary only; signing lives in ironclaw_host_runtime. - ("ironclaw_extension_contracts", 7_752), + // 7_752 -> 7_758 (2026-08-09, notifications capability): +6 lines for the + // `ChannelDescriptor.notifications` field + its doc — a manifest capability + // declaration only; notification delivery gating lives in + // ironclaw_outbound (DeliveryTargetCapabilities) and product resolution. + ("ironclaw_extension_contracts", 7_758), // Raised 17_501 -> 18_570 by #6831 (standardized messaging framework): // the growth is the `messaging` vocabulary — the StandardMessagingOp // enum, the 12-code error taxonomy, compiled-in canonical schema/prompt diff --git a/crates/app/ironclaw_composition/src/runtime.rs b/crates/app/ironclaw_composition/src/runtime.rs index bf3b0ca962f..f94e5cd157f 100644 --- a/crates/app/ironclaw_composition/src/runtime.rs +++ b/crates/app/ironclaw_composition/src/runtime.rs @@ -1845,6 +1845,10 @@ impl RebornRuntime { progress: false, gate_prompts: false, auth_prompts: false, + // A registered channel DM is both a final-reply target and a + // notification channel, mirroring the generic provider's + // `full_capabilities` (Slack/Telegram). + notifications: true, modalities: Vec::new(), }, reply_target_binding_ref, diff --git a/crates/app/ironclaw_composition/src/runtime/capability_host/tests.rs b/crates/app/ironclaw_composition/src/runtime/capability_host/tests.rs index c9580262b17..829614b0257 100644 --- a/crates/app/ironclaw_composition/src/runtime/capability_host/tests.rs +++ b/crates/app/ironclaw_composition/src/runtime/capability_host/tests.rs @@ -3996,6 +3996,7 @@ mod tests { progress: false, gate_prompts: false, auth_prompts: false, + notifications: true, modalities: Vec::new(), }; let slack_reply_target = @@ -4245,6 +4246,7 @@ mod tests { progress: false, gate_prompts: false, auth_prompts: false, + notifications: true, modalities: Vec::new(), }, destination: slack_reply_target.clone(), @@ -4480,6 +4482,7 @@ mod tests { progress: false, gate_prompts: false, auth_prompts: false, + notifications: true, modalities: Vec::new(), }, destination: slack_reply_target, diff --git a/crates/contracts/ironclaw_extension_contracts/src/channel.rs b/crates/contracts/ironclaw_extension_contracts/src/channel.rs index 1a81454f614..11b0a52b71e 100644 --- a/crates/contracts/ironclaw_extension_contracts/src/channel.rs +++ b/crates/contracts/ironclaw_extension_contracts/src/channel.rs @@ -104,6 +104,12 @@ pub struct ChannelDescriptor { pub inbound: bool, #[serde(default)] pub outbound: bool, + /// This channel can fulfil blocked-automation notifications (approval/auth + /// gates, failure notices). Independent of `inbound`/`outbound`: a channel + /// may deliver notifications without being a two-way conversation surface + /// (the web app declares this without yet declaring outbound delivery). + #[serde(default)] + pub notifications: bool, /// Required: how external conversations bind (checklist MAN-10). pub conversation_model: ConversationModel, /// Exact product command tokens exposed by this channel, without a leading diff --git a/crates/domains/ironclaw_outbound/src/delivery_resolution.rs b/crates/domains/ironclaw_outbound/src/delivery_resolution.rs index c5a68db9e9e..61c3d12f28c 100644 --- a/crates/domains/ironclaw_outbound/src/delivery_resolution.rs +++ b/crates/domains/ironclaw_outbound/src/delivery_resolution.rs @@ -184,6 +184,12 @@ pub struct DeliveryTargetCapabilities { pub progress: bool, pub gate_prompts: bool, pub auth_prompts: bool, + /// This target receives blocked-automation notifications (approval-gate, + /// auth, and failure notices). Independent of `final_replies`: the web app + /// is a notification target but not a model/final-reply delivery target + /// (until it gains outbound thread creation). + #[serde(default)] + pub notifications: bool, pub modalities: Vec, } @@ -420,6 +426,7 @@ mod tests { progress: true, gate_prompts: false, auth_prompts: true, + notifications: true, modalities: vec![CommunicationModality::Text, CommunicationModality::Mixed], }; diff --git a/crates/domains/ironclaw_outbound/src/delivery_targets.rs b/crates/domains/ironclaw_outbound/src/delivery_targets.rs index 6a007ffa8fc..03311fc4c87 100644 --- a/crates/domains/ironclaw_outbound/src/delivery_targets.rs +++ b/crates/domains/ironclaw_outbound/src/delivery_targets.rs @@ -218,6 +218,25 @@ pub trait OutboundDeliveryTargetProvider: Send + Sync { .into_iter() .find(|entry| entry.capabilities.final_replies && entry.destination == *target)) } + + /// Resolve a target the caller may receive blocked-automation + /// notifications on. Gated on `notifications`, NOT `final_replies`, so a + /// notification-only target (the web app's browser push) resolves here yet + /// never through `resolve_outbound_delivery_target`. + async fn resolve_notification_target( + &self, + scope: &OutboundDeliveryTargetScope, + target_id: &OutboundDeliveryTargetId, + ) -> Result, OutboundError> { + Ok(self + .list_outbound_delivery_targets(scope) + .await? + .into_iter() + .find(|entry| { + entry.capabilities.notifications + && entry.summary.target_id.as_str() == target_id.as_str() + })) + } } pub struct OutboundDeliveryTargetRegistry { @@ -343,6 +362,16 @@ impl OutboundDeliveryTargetProvider for MutableOutboundDeliveryTargetRegistry { .resolve_reply_target_binding(scope, target) .await } + + async fn resolve_notification_target( + &self, + scope: &OutboundDeliveryTargetScope, + target_id: &OutboundDeliveryTargetId, + ) -> Result, OutboundError> { + OutboundDeliveryTargetRegistry::new(self.providers()?) + .resolve_notification_target(scope, target_id) + .await + } } #[async_trait] @@ -398,6 +427,25 @@ impl OutboundDeliveryTargetProvider for OutboundDeliveryTargetRegistry { } Ok(None) } + + async fn resolve_notification_target( + &self, + scope: &OutboundDeliveryTargetScope, + target_id: &OutboundDeliveryTargetId, + ) -> Result, OutboundError> { + for provider in &self.providers { + if let Some(entry) = provider + .resolve_notification_target(scope, target_id) + .await? + .filter(|entry| { + entry.owner.matches_scope(scope) && entry.capabilities.notifications + }) + { + return Ok(Some(entry)); + } + } + Ok(None) + } } fn validate_outbound_delivery_display_field( @@ -588,6 +636,7 @@ mod tests { progress: !final_replies, gate_prompts: true, auth_prompts: true, + notifications: true, modalities: Vec::new(), }, destination: reply_ref(format!("reply:{target_id_value}")), diff --git a/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_tests.rs b/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_tests.rs index 8fd76e10a59..1bb50ef1c74 100644 --- a/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_tests.rs +++ b/crates/extensions/ironclaw_extension_host/src/channel_host/e2e_tests.rs @@ -1307,6 +1307,7 @@ impl ironclaw_outbound::OutboundDeliveryTargetProvider for StaticNotificationTar progress: false, gate_prompts: true, auth_prompts: true, + notifications: true, modalities: Vec::new(), }, destination: self.destination.clone(), diff --git a/crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs b/crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs index 58b1e66c36a..f2b9c73bfc9 100644 --- a/crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs +++ b/crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs @@ -335,6 +335,18 @@ impl OutboundDeliveryTargetProvider for GenericChannelOutboundTargetProvider { Ok(None) } + async fn resolve_notification_target( + &self, + caller: &OutboundDeliveryTargetScope, + target_id: &OutboundDeliveryTargetId, + ) -> Result, OutboundError> { + // Generic channels (Slack/Telegram DMs) are both final-reply and + // notification targets; id resolution is identical, and the registry + // applies the `notifications` capability post-filter. + self.resolve_outbound_delivery_target(caller, target_id) + .await + } + async fn resolve_reply_target_binding( &self, caller: &OutboundDeliveryTargetScope, @@ -414,6 +426,7 @@ fn full_capabilities() -> DeliveryTargetCapabilities { progress: false, gate_prompts: true, auth_prompts: true, + notifications: true, modalities: Vec::new(), } } diff --git a/crates/extensions/packages/web-push/manifest.toml b/crates/extensions/packages/web-push/manifest.toml index 7aeee14d6f0..4708b6568b0 100644 --- a/crates/extensions/packages/web-push/manifest.toml +++ b/crates/extensions/packages/web-push/manifest.toml @@ -40,6 +40,7 @@ id = "notifications" display_name = "Web UI" inbound = false outbound = true +notifications = true conversation_model = "continuous" # Exact push-service hosts, in lockstep with diff --git a/crates/extensions/packages/web-push/src/targets.rs b/crates/extensions/packages/web-push/src/targets.rs index a245322636d..bf51fcfc68a 100644 --- a/crates/extensions/packages/web-push/src/targets.rs +++ b/crates/extensions/packages/web-push/src/targets.rs @@ -62,10 +62,15 @@ fn web_push_entry( Ok(OutboundDeliveryTargetEntry { summary, capabilities: DeliveryTargetCapabilities { - final_replies: true, + // The web app is a NOTIFICATION target (blocked-automation notices), + // not a final-reply/model-delivery target — a run's reply already + // lands in the web app; browser push is only for notices. Outbound + // thread creation is a later capability. + final_replies: false, progress: false, gate_prompts: true, auth_prompts: true, + notifications: true, modalities: vec![CommunicationModality::Text], }, destination, @@ -111,7 +116,11 @@ mod tests { let entry = &entries[0]; assert_eq!(entry.summary.target_id.as_str(), WEB_PUSH_TARGET_ID); assert_eq!(entry.summary.channel.as_str(), WEB_PUSH_CHANNEL_NAME); - assert!(entry.capabilities.final_replies); + assert!( + !entry.capabilities.final_replies, + "the web app is a notification target, not a final-reply/model-delivery target" + ); + assert!(entry.capabilities.notifications); assert!(entry.capabilities.gate_prompts); assert!(entry.capabilities.auth_prompts); assert!(!entry.capabilities.progress); @@ -123,15 +132,27 @@ mod tests { } #[tokio::test] - async fn resolve_by_id_uses_the_default_final_replies_gate() { + async fn resolves_as_a_notification_target_but_not_a_final_reply_target() { let provider = WebPushOutboundTargetProvider::new(); - let resolved = provider - .resolve_outbound_delivery_target( - &scope(), - &OutboundDeliveryTargetId::new(WEB_PUSH_TARGET_ID).expect("id"), - ) + let id = OutboundDeliveryTargetId::new(WEB_PUSH_TARGET_ID).expect("id"); + // Notification path (blocked-automation notices) resolves it. + let notification = provider + .resolve_notification_target(&scope(), &id) .await .expect("resolve"); - assert!(resolved.is_some(), "final_replies=true keeps it resolvable"); + assert!( + notification.is_some(), + "notifications=true keeps it resolvable as a notification target" + ); + // Model/final-reply path must NOT resolve it: browser push is not where + // a final reply or a model-chosen delivery lands. + let final_reply = provider + .resolve_outbound_delivery_target(&scope(), &id) + .await + .expect("resolve"); + assert!( + final_reply.is_none(), + "final_replies=false keeps it out of model/final-reply delivery" + ); } } diff --git a/crates/extensions/packages/web-push/tests/manifest_lockstep.rs b/crates/extensions/packages/web-push/tests/manifest_lockstep.rs index 33d8e5ee99e..37fb4375ce0 100644 --- a/crates/extensions/packages/web-push/tests/manifest_lockstep.rs +++ b/crates/extensions/packages/web-push/tests/manifest_lockstep.rs @@ -75,4 +75,9 @@ fn manifest_identity_matches_the_grammar_constants() { channel.get("outbound").and_then(toml::Value::as_bool), Some(true) ); + assert_eq!( + channel.get("notifications").and_then(toml::Value::as_bool), + Some(true), + "the web app declares the notifications capability" + ); } diff --git a/crates/product/ironclaw_assistant/src/model_channel_delivery/tests.rs b/crates/product/ironclaw_assistant/src/model_channel_delivery/tests.rs index a8adb3c5200..df7c70d89af 100644 --- a/crates/product/ironclaw_assistant/src/model_channel_delivery/tests.rs +++ b/crates/product/ironclaw_assistant/src/model_channel_delivery/tests.rs @@ -110,6 +110,7 @@ fn external_target_entry(id: &str, binding: &str) -> OutboundDeliveryTargetEntry progress: false, gate_prompts: false, auth_prompts: false, + notifications: true, modalities: Vec::new(), }, destination: reply_ref(binding), diff --git a/crates/product/ironclaw_assistant/src/notification_channel_resolution.rs b/crates/product/ironclaw_assistant/src/notification_channel_resolution.rs index 6aece88c7fb..b2cc5823d94 100644 --- a/crates/product/ironclaw_assistant/src/notification_channel_resolution.rs +++ b/crates/product/ironclaw_assistant/src/notification_channel_resolution.rs @@ -106,7 +106,7 @@ pub(crate) async fn resolve_effective_notification_channels( } for target_id in effective_ids { match targets - .resolve_outbound_delivery_target(scope, &target_id) + .resolve_notification_target(scope, &target_id) .await { Ok(Some(entry)) => channels.push(EffectiveNotificationChannel::Resolved(entry)), diff --git a/crates/product/ironclaw_assistant/src/reborn_services/outbound_delivery_capability_surface.rs b/crates/product/ironclaw_assistant/src/reborn_services/outbound_delivery_capability_surface.rs index 02f7c76f6fc..369d4232887 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/outbound_delivery_capability_surface.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/outbound_delivery_capability_surface.rs @@ -95,6 +95,13 @@ pub async fn list_outbound_delivery_targets_for_model( input: OutboundDeliveryTargetsListInput, ) -> Result { let mut response = service.list_outbound_delivery_targets(caller).await?; + // The product list is the notification-channel picker (gated on + // `notifications`); the model may only deliver to final-reply-capable + // targets, so narrow here. A notification-only target (browser push) is + // therefore invisible to the model until it gains outbound delivery. + response + .targets + .retain(|option| option.capabilities.final_replies); if let Some(channel_filter) = input.channel { response.targets.retain(|option| { option diff --git a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs index 69c45114b58..97955edbe26 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs @@ -67,7 +67,10 @@ impl OutboundPreferencesProductService for RebornOutboundPreferencesService { .await .map_err(map_outbound_repository_error)? .into_iter() - .filter(|entry| entry.capabilities.final_replies) + // Notification-channel picker: any target that can receive + // blocked-automation notices. The model-facing delivery list narrows + // this to `final_replies` in `list_outbound_delivery_targets_for_model`. + .filter(|entry| entry.capabilities.notifications) .map(|entry| { Ok(RebornOutboundDeliveryTargetOption { target: reborn_summary_from_outbound(&entry.summary)?, @@ -256,6 +259,17 @@ mod tests { ) -> Result, OutboundError> { Ok((self.entry.destination.as_str() == target.as_str()).then(|| self.entry.clone())) } + + async fn resolve_notification_target( + &self, + _caller: &OutboundDeliveryTargetScope, + target_id: &OutboundDeliveryTargetId, + ) -> Result, OutboundError> { + Ok( + (self.entry.summary.target_id.as_str() == target_id.as_str()) + .then(|| self.entry.clone()), + ) + } } struct ResolveFailingTargetProvider; @@ -284,6 +298,14 @@ mod tests { ) -> Result, OutboundError> { Err(OutboundError::Backend) } + + async fn resolve_notification_target( + &self, + _caller: &OutboundDeliveryTargetScope, + _target_id: &OutboundDeliveryTargetId, + ) -> Result, OutboundError> { + Err(OutboundError::Backend) + } } struct NullResolvingTargetProvider; @@ -312,6 +334,14 @@ mod tests { ) -> Result, OutboundError> { Ok(None) } + + async fn resolve_notification_target( + &self, + _caller: &OutboundDeliveryTargetScope, + _target_id: &OutboundDeliveryTargetId, + ) -> Result, OutboundError> { + Ok(None) + } } struct LoadFailingPreferenceRepository; diff --git a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/notification_channels.rs b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/notification_channels.rs index 36a2d467425..c0e45222beb 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/notification_channels.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/notification_channels.rs @@ -37,7 +37,7 @@ pub(super) async fn set_notification_channels( let outbound_id = notification_channel_target_id_from_reborn(reborn_id)?; let entry = service .targets - .resolve_outbound_delivery_target(&scope, &outbound_id) + .resolve_notification_target(&scope, &outbound_id) .await .map_err(map_outbound_repository_error)? .ok_or_else(notification_channel_not_found)?; diff --git a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/support_tests.rs b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/support_tests.rs index 4821802e9e8..f61b59448c3 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/support_tests.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/support_tests.rs @@ -81,6 +81,9 @@ pub(super) fn target_entry_for_channel( progress: false, gate_prompts: true, auth_prompts: true, + // Track final_replies so existing capability-filter tests hold: the + // notification picker now filters on `notifications`. + notifications: final_replies, modalities: Vec::new(), }, destination: reply_ref(reply_target), diff --git a/crates/product/ironclaw_assistant/tests/run_delivery_contract.rs b/crates/product/ironclaw_assistant/tests/run_delivery_contract.rs index 6a822ebd3ab..f7c326e6fa8 100644 --- a/crates/product/ironclaw_assistant/tests/run_delivery_contract.rs +++ b/crates/product/ironclaw_assistant/tests/run_delivery_contract.rs @@ -645,6 +645,7 @@ impl OutboundDeliveryTargetProvider for StaticTargetCatalog { progress: false, gate_prompts: true, auth_prompts: true, + notifications: true, modalities: Vec::new(), }, destination: ReplyTargetBindingRef::new(entry.binding_ref).expect("binding ref"), From da7b0485ed5fa4c472f184b61bea33510da56222 Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sun, 9 Aug 2026 15:25:24 -0400 Subject: [PATCH 10/13] fix(channels): cargo fmt + drop vendor names from generic-code comments - cargo fmt reflow in notification_channel_resolution.rs. - Reword comments in ironclaw_composition/runtime.rs and ironclaw_extension_host/channel_outbound_targets.rs so generic code does not name Slack/Telegram (reborn_extension_specificity gate). Co-Authored-By: Claude Opus 4.8 --- crates/app/ironclaw_composition/src/runtime.rs | 4 ++-- .../ironclaw_extension_host/src/channel_outbound_targets.rs | 6 +++--- .../src/notification_channel_resolution.rs | 5 +---- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/app/ironclaw_composition/src/runtime.rs b/crates/app/ironclaw_composition/src/runtime.rs index f94e5cd157f..b7422007d72 100644 --- a/crates/app/ironclaw_composition/src/runtime.rs +++ b/crates/app/ironclaw_composition/src/runtime.rs @@ -1846,8 +1846,8 @@ impl RebornRuntime { gate_prompts: false, auth_prompts: false, // A registered channel DM is both a final-reply target and a - // notification channel, mirroring the generic provider's - // `full_capabilities` (Slack/Telegram). + // notification channel, mirroring the generic channel + // provider's `full_capabilities`. notifications: true, modalities: Vec::new(), }, diff --git a/crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs b/crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs index f2b9c73bfc9..9fc14d90dd1 100644 --- a/crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs +++ b/crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs @@ -340,9 +340,9 @@ impl OutboundDeliveryTargetProvider for GenericChannelOutboundTargetProvider { caller: &OutboundDeliveryTargetScope, target_id: &OutboundDeliveryTargetId, ) -> Result, OutboundError> { - // Generic channels (Slack/Telegram DMs) are both final-reply and - // notification targets; id resolution is identical, and the registry - // applies the `notifications` capability post-filter. + // A channel DM is both a final-reply and a notification target; id + // resolution is identical, and the registry applies the `notifications` + // capability post-filter. self.resolve_outbound_delivery_target(caller, target_id) .await } diff --git a/crates/product/ironclaw_assistant/src/notification_channel_resolution.rs b/crates/product/ironclaw_assistant/src/notification_channel_resolution.rs index b2cc5823d94..9f9353e1248 100644 --- a/crates/product/ironclaw_assistant/src/notification_channel_resolution.rs +++ b/crates/product/ironclaw_assistant/src/notification_channel_resolution.rs @@ -105,10 +105,7 @@ pub(crate) async fn resolve_effective_notification_channels( channels.push(EffectiveNotificationChannel::LegacyUnresolvable { reply_ref }); } for target_id in effective_ids { - match targets - .resolve_notification_target(scope, &target_id) - .await - { + match targets.resolve_notification_target(scope, &target_id).await { Ok(Some(entry)) => channels.push(EffectiveNotificationChannel::Resolved(entry)), Ok(None) => channels.push(EffectiveNotificationChannel::Missing { target_id }), Err(error) => match lookup_errors { From 07b1925902c33ff7c07e1c09756e931feef40f60 Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sun, 9 Aug 2026 15:56:50 -0400 Subject: [PATCH 11/13] docs+test(channels): fix notifications doc + serde-default coverage Addresses CodeRabbit on the notifications-capability commit: - channel.rs: reword ChannelDescriptor.notifications doc so it no longer claims the web app declines outbound (the manifest keeps outbound = true for the delivery binding). Net-zero line count (contracts size ceiling unchanged). - delivery_resolution.rs: assert notifications defaults false, and add a legacy-payload (omitted notifications) deserialize test for #[serde(default)]. Co-Authored-By: Claude Opus 4.8 --- .../ironclaw_extension_contracts/src/channel.rs | 2 +- .../ironclaw_outbound/src/delivery_resolution.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/contracts/ironclaw_extension_contracts/src/channel.rs b/crates/contracts/ironclaw_extension_contracts/src/channel.rs index 11b0a52b71e..8b7d28c59e9 100644 --- a/crates/contracts/ironclaw_extension_contracts/src/channel.rs +++ b/crates/contracts/ironclaw_extension_contracts/src/channel.rs @@ -107,7 +107,7 @@ pub struct ChannelDescriptor { /// This channel can fulfil blocked-automation notifications (approval/auth /// gates, failure notices). Independent of `inbound`/`outbound`: a channel /// may deliver notifications without being a two-way conversation surface - /// (the web app declares this without yet declaring outbound delivery). + /// (e.g. a browser-push channel, notification-only for final replies). #[serde(default)] pub notifications: bool, /// Required: how external conversations bind (checklist MAN-10). diff --git a/crates/domains/ironclaw_outbound/src/delivery_resolution.rs b/crates/domains/ironclaw_outbound/src/delivery_resolution.rs index 61c3d12f28c..cae4c3ccca7 100644 --- a/crates/domains/ironclaw_outbound/src/delivery_resolution.rs +++ b/crates/domains/ironclaw_outbound/src/delivery_resolution.rs @@ -444,9 +444,23 @@ mod tests { assert!(!capabilities.progress); assert!(!capabilities.gate_prompts); assert!(!capabilities.auth_prompts); + assert!(!capabilities.notifications); assert!(capabilities.modalities.is_empty()); } + #[test] + fn delivery_target_capabilities_deserialize_defaults_notifications_to_false() { + // A historical payload predating the `notifications` capability must + // deserialize with `notifications = false` via `#[serde(default)]`, + // never fail — preserving wire/persist compatibility. + let decoded: DeliveryTargetCapabilities = from_str( + r#"{"final_replies":true,"progress":false,"gate_prompts":true,"auth_prompts":true,"modalities":[]}"#, + ) + .expect("deserialize legacy capabilities without notifications"); + assert!(!decoded.notifications); + assert!(decoded.final_replies); + } + #[test] fn system_event_reason_code_rejects_unknown_variants() { assert_json_round_trip(SystemEventReasonCode::Generic); From f761742326cd9614777be909b4367db4bcf3147e Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Sun, 9 Aug 2026 23:35:58 -0400 Subject: [PATCH 12/13] fix(outbound): decouple notification-picker list from model-delivery list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the CodeRabbit "Major": final_replies and notifications were coupled through one shared base filter. Now RebornOutboundDeliveryTargetCapabilities carries a wire-level `notifications`; list_outbound_delivery_targets returns the union (final_replies || notifications); the notification picker (build_outbound_delivery_targets_view) filters notifications and the model list (list_outbound_delivery_targets_for_model) filters final_replies — independently. A final-reply-only target is now visible to the model but not the picker; a notification-only target (web-push) shows in the picker but never to the model. Verified: assistant, ironclaw_webui, frontend (1208), delivery_user_journeys (25), web-push round-trip, architecture suite (41). Chose "in #7398" per the overnight directive to fix review comments. Co-Authored-By: Claude Opus 4.8 --- .../ironclaw_product_contracts/src/product_wire.rs | 5 +++++ .../src/reborn_services/outbound_preferences.rs | 9 +++++---- .../src/reborn_services/outbound_views.rs | 14 ++++++++++++-- .../tests/reborn_services_contract.rs | 3 +++ .../tests/webui_v2_handlers_contract.rs | 3 +++ tests/integration/support/outbound_preferences.rs | 3 +++ 6 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/contracts/ironclaw_product_contracts/src/product_wire.rs b/crates/contracts/ironclaw_product_contracts/src/product_wire.rs index 7f4159c839c..2633ae607f2 100644 --- a/crates/contracts/ironclaw_product_contracts/src/product_wire.rs +++ b/crates/contracts/ironclaw_product_contracts/src/product_wire.rs @@ -799,6 +799,11 @@ pub struct RebornOutboundDeliveryTargetCapabilities { pub final_replies: bool, pub gate_prompts: bool, pub auth_prompts: bool, + /// This target can receive blocked-automation notifications. Independent of + /// `final_replies`: the notification-channel picker filters on this, the + /// model-delivery list filters on `final_replies`. + #[serde(default)] + pub notifications: bool, } /// Client-safe opaque outbound delivery target id. diff --git a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs index 97955edbe26..520765d9ea8 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs @@ -67,10 +67,10 @@ impl OutboundPreferencesProductService for RebornOutboundPreferencesService { .await .map_err(map_outbound_repository_error)? .into_iter() - // Notification-channel picker: any target that can receive - // blocked-automation notices. The model-facing delivery list narrows - // this to `final_replies` in `list_outbound_delivery_targets_for_model`. - .filter(|entry| entry.capabilities.notifications) + // Base list is the UNION of both capabilities; each consumer narrows + // to the one it needs (picker → notifications, model → final_replies), + // keeping the two capabilities independent. + .filter(|entry| entry.capabilities.final_replies || entry.capabilities.notifications) .map(|entry| { Ok(RebornOutboundDeliveryTargetOption { target: reborn_summary_from_outbound(&entry.summary)?, @@ -143,6 +143,7 @@ fn reborn_capabilities_from_outbound( final_replies: capabilities.final_replies, gate_prompts: capabilities.gate_prompts, auth_prompts: capabilities.auth_prompts, + notifications: capabilities.notifications, } } diff --git a/crates/product/ironclaw_assistant/src/reborn_services/outbound_views.rs b/crates/product/ironclaw_assistant/src/reborn_services/outbound_views.rs index 5950e3b8de0..4845b8029fe 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/outbound_views.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/outbound_views.rs @@ -38,9 +38,19 @@ where &self, caller: ProductSurfaceCaller, ) -> Result { - self.outbound_preferences_service + // The notification-channel picker shows notification-capable targets; + // the model-facing list (`list_outbound_delivery_targets_for_model`) + // narrows the same union base to `final_replies`, so the two + // capabilities stay independent (a final-reply-only target is never + // shown here, and a notification-only target is never shown to the model). + let mut response = self + .outbound_preferences_service .list_outbound_delivery_targets(caller) - .await + .await?; + response + .targets + .retain(|option| option.capabilities.notifications); + Ok(response) } pub(super) async fn build_notification_channels_view( diff --git a/crates/product/ironclaw_assistant/tests/reborn_services_contract.rs b/crates/product/ironclaw_assistant/tests/reborn_services_contract.rs index 16793bc55d7..046a1da4d3a 100644 --- a/crates/product/ironclaw_assistant/tests/reborn_services_contract.rs +++ b/crates/product/ironclaw_assistant/tests/reborn_services_contract.rs @@ -1633,6 +1633,7 @@ impl OutboundPreferencesProductService for RecordingOutboundPreferencesService { final_replies: true, gate_prompts: true, auth_prompts: true, + notifications: true, }, }], next_cursor: None, @@ -7026,6 +7027,7 @@ fn outbound_target_list_response_preserves_json_shape_with_cursor() { final_replies: true, gate_prompts: true, auth_prompts: true, + notifications: true, }, }], next_cursor: Some("opaque-page-token".to_string()), @@ -7046,6 +7048,7 @@ fn outbound_target_list_response_preserves_json_shape_with_cursor() { "final_replies": true, "gate_prompts": true, "auth_prompts": true, + "notifications": true, }, }], "next_cursor": "opaque-page-token", diff --git a/crates/product/ironclaw_webui/tests/webui_v2_handlers_contract.rs b/crates/product/ironclaw_webui/tests/webui_v2_handlers_contract.rs index 42eadd920be..e6d5da5b696 100644 --- a/crates/product/ironclaw_webui/tests/webui_v2_handlers_contract.rs +++ b/crates/product/ironclaw_webui/tests/webui_v2_handlers_contract.rs @@ -2073,6 +2073,7 @@ fn notification_channels_response() -> RebornNotificationChannelsResponse { final_replies: true, gate_prompts: true, auth_prompts: true, + notifications: true, }, }), }, @@ -2094,6 +2095,7 @@ fn outbound_delivery_targets_response() -> RebornOutboundDeliveryTargetListRespo final_replies: true, gate_prompts: true, auth_prompts: true, + notifications: true, }, }, RebornOutboundDeliveryTargetOption { @@ -2108,6 +2110,7 @@ fn outbound_delivery_targets_response() -> RebornOutboundDeliveryTargetListRespo final_replies: false, gate_prompts: false, auth_prompts: false, + notifications: false, }, }, ], diff --git a/tests/integration/support/outbound_preferences.rs b/tests/integration/support/outbound_preferences.rs index 5ec5495fc2e..b69d469fe3c 100644 --- a/tests/integration/support/outbound_preferences.rs +++ b/tests/integration/support/outbound_preferences.rs @@ -116,6 +116,7 @@ impl OutboundPreferencesProductService for FakeOutboundPreferencesService { final_replies: true, gate_prompts: true, auth_prompts: true, + notifications: true, }, }), }); @@ -155,6 +156,7 @@ impl OutboundPreferencesProductService for FakeOutboundPreferencesService { final_replies: true, gate_prompts: true, auth_prompts: true, + notifications: true, }, }), }, @@ -182,6 +184,7 @@ fn target_option(target_id: &str, display_name: &str) -> RebornOutboundDeliveryT final_replies: true, gate_prompts: true, auth_prompts: true, + notifications: true, }, } } From 61c83b5e4e7ff518d87c4f1fb622eb5982a09d9d Mon Sep 17 00:00:00 2001 From: BenKurrek Date: Mon, 10 Aug 2026 00:09:48 -0400 Subject: [PATCH 13/13] test(outbound): cover divergent notifications/final_replies at the filter seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit's follow-up: the unit capability-filter seam couldn't construct divergent targets (the fixture set notifications = final_replies). Adds `target_entry_with_caps` (independent notifications) and a divergent-combo test asserting the base list is the union — a final-reply-only target and a notification-only target both survive, a neither-capable target is excluded. Existing coincident-case tests unchanged. Co-Authored-By: Claude Opus 4.8 --- .../reborn_services/outbound_preferences.rs | 66 +++++++++++++++++++ .../outbound_preferences/support_tests.rs | 27 +++++++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs index 520765d9ea8..9f59fd6975e 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences.rs @@ -586,6 +586,72 @@ mod tests { assert!(response.next_cursor.is_none()); } + /// The base list is the UNION of `final_replies` and `notifications`, and the + /// two are INDEPENDENT: a final-reply-only target (a model-delivery target, + /// not a notification channel) and a notification-only target (a notification + /// channel, not a model target — like web-push) both survive the base; + /// a target with neither capability is excluded. The picker + /// (`build_outbound_delivery_targets_view`) and the model list + /// (`list_outbound_delivery_targets_for_model`) then narrow this base to + /// their own capability. + #[tokio::test] + async fn list_targets_returns_the_union_of_final_reply_and_notification_capabilities() { + let store = + Arc::new(ironclaw_outbound::test_support::in_memory_backed_outbound_state_store()); + let provider = Arc::new(FakeTargetProvider::default()); + provider.insert( + "user-alpha", + target_entry_with_caps( + "final-only", + "slack", + "Final only", + "reply:final-only", + true, + false, + ), + ); + provider.insert( + "user-alpha", + target_entry_with_caps( + "notif-only", + "web-push", + "Notif only", + "reply:notif-only", + false, + true, + ), + ); + provider.insert( + "user-alpha", + target_entry_with_caps("neither", "slack", "Neither", "reply:neither", false, false), + ); + let service = RebornOutboundPreferencesService::new(store, provider); + + let response = service + .list_outbound_delivery_targets(caller("tenant-alpha", "user-alpha")) + .await + .expect("target list"); + + let ids: Vec<&str> = response + .targets + .iter() + .map(|entry| entry.target.target_id.as_str()) + .collect(); + assert!( + ids.contains(&"final-only"), + "a final-reply-only target must survive the union base: {ids:?}" + ); + assert!( + ids.contains(&"notif-only"), + "a notification-only target must survive the union base: {ids:?}" + ); + assert!( + !ids.contains(&"neither"), + "a target with neither capability must be excluded: {ids:?}" + ); + assert_eq!(response.targets.len(), 2); + } + /// A notification-channel write resolves ids through the authority /// resolver, never through the public target list: the provider below /// lists nothing yet resolves the id, and the set still succeeds and diff --git a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/support_tests.rs b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/support_tests.rs index f61b59448c3..dac331f09e1 100644 --- a/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/support_tests.rs +++ b/crates/product/ironclaw_assistant/src/reborn_services/outbound_preferences/support_tests.rs @@ -67,6 +67,29 @@ pub(super) fn target_entry_for_channel( display_name: &str, reply_target: &str, final_replies: bool, +) -> OutboundDeliveryTargetEntry { + // The coincident case (a full channel target is both a final-reply and a + // notification target); divergent combinations use `target_entry_with_caps`. + target_entry_with_caps( + target_id_value, + channel, + display_name, + reply_target, + final_replies, + final_replies, + ) +} + +/// Build a target with INDEPENDENT `final_replies` and `notifications` +/// capabilities. The two are separate concerns: the model-delivery list filters +/// `final_replies`, the notification picker filters `notifications`. +pub(super) fn target_entry_with_caps( + target_id_value: &str, + channel: &str, + display_name: &str, + reply_target: &str, + final_replies: bool, + notifications: bool, ) -> OutboundDeliveryTargetEntry { OutboundDeliveryTargetEntry { summary: OutboundDeliveryTargetSummary::new( @@ -81,9 +104,7 @@ pub(super) fn target_entry_for_channel( progress: false, gate_prompts: true, auth_prompts: true, - // Track final_replies so existing capability-filter tests hold: the - // notification picker now filters on `notifications`. - notifications: final_replies, + notifications, modalities: Vec::new(), }, destination: reply_ref(reply_target),