Skip to content

DEVX-1016: Sign Rootscribe outbound webhooks with HMAC-SHA256 and send timestamp and instance-id headers - #19

Merged
allenahner merged 30 commits into
mainfrom
allenahner/DEVX-1016-sign-outbound-webhooks-hmac-sha256
Sep 22, 2026
Merged

allenahner merged 30 commits into
mainfrom
allenahner/DEVX-1016-sign-outbound-webhooks-hmac-sha256

Conversation

@allenahner

@allenahner allenahner commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

Summary

Outbound webhooks can now be verified by their receivers, and receivers can tell RootScribe installs apart.

  • HMAC-SHA256 signing. When a webhook secret is configured, every delivery — fireRaw retries and the Settings/wizard "Test" send alike — carries x-rootscribe-timestamp (Unix seconds) and x-rootscribe-signature: t=<sec>,v1=<hex>, where v1 is the HMAC-SHA256 of ${t}.${body} over the exact JSON string handed to fetch. The timestamp is recomputed per attempt so a retry after the 5s/30s backoff still lands inside a receiver's tolerance window. Primitive: server/src/webhook/sign.ts → signWebhook(secret, timestampSec, body).
  • Instance id. New AppConfig.instanceId (default null), minted as a UUID on first run by ensureInstanceId() (called at startup and by every delivery so the header is never absent even if settings.json was hand-edited). Sent as x-rootscribe-instance on every delivery, signed or not. POST /api/config accepts it as a trimmed, header-safe token (1..128 chars of [A-Za-z0-9._:-]) — undici's fetch throws on control chars, which would silently break every delivery.
  • Unsigned warning. Without a secret, the server logs once per process (module-level latch) that deliveries are unsigned.
  • Write-only secret (review round 1). GET/POST /api/config never return webhook.secret — the API has no auth and Docker binds 0.0.0.0, so a LAN client could otherwise forge HMAC-valid deliveries. Responses carry webhook.secretConfigured instead (mirrors the token redaction). On POST, webhook.secret is tri-state: omitted = keep the stored value, "" = clear, non-empty = replace — so unrelated saves can't wipe a stored secret.
  • Settings UI. Webhook Outbound gains a Signing Secret input (write-only draft with a "configured" placeholder) + Generate / Clear buttons (32 CSPRNG bytes as 64 hex chars) and an editable Instance ID. A blank instance id is omitted (keeps the minted value) rather than sent (server would reject it).
  • Setup wizard. WebhookStep gets the same secret input + Generate and a read-only instance-id display via the config query.
  • Draft-aware Test (review round 1). POST /api/config/test-webhook accepts an optional secret; testWebhook(url, secret?) signs with it (undefined = stored, "" = unsigned). Settings and the wizard pass the field's current value so Test verifies the value about to be saved.
  • Corrupt config is never overwritten (review round 1). ensureInstanceId() mints in-memory only when settings.json exists but failed to parse, so startup can't clobber a hand-recoverable file with defaults + a UUID.
  • README. New "Webhook signing" section (header table + Node verification recipe with timingSafeEqual and a replay window; the recipe was exercised against real signWebhook output). Headers bullet updated. Payload example now includes end_time_ms, which the code has always sent.
  • Release. 0.1.1 → 0.2.0 (minor: new functionality) across the same file set as the 0.1.1 hotfix bump; CHANGELOG [0.2.0] section added. The two "rootscribe/0.1.1" UA literals in post.ts are consolidated into one USER_AGENT constant.

Related Issues

  • Resolves: DEVX-1016
  • Epic: DEVX-1001 (Phase 3c — single-child collapse, so this PR targets main directly and carries the version bump)

Acceptance criteria → evidence

Scenario Covered by
Signed delivery: x-rootscribe-signature with t and v1, v1 verifies against the body server/tests/webhook/post.test.ts "signed delivery…" + "re-signs every retry attempt…" (recomputes the HMAC in the test, no mocks)
Instance header always present; no signature without a secret post.test.ts "always sends x-rootscribe-instance…", "no secret configured…", "mints and persists an instance id on the fly…"
Test webhook signed the same way post.test.ts "testWebhook — signing + instance headers"
Settings persists secret + instance id; subsequent deliveries use them tests/e2e/settings.spec.ts (UI reload + GET /api/config), server/tests/routes/config.test.ts instanceId cases, Settings.test.tsx save-payload cases

Test Plan

  • TDD workflow followed (RED-GREEN-REFACTOR) — every production change was preceded by a failing test shown in the transcript
  • Unit tests passing — pnpm test 830/830 (repeated clean full-suite runs)
  • Coverage thresholds held — statements 95.52 / branches 86.97 / functions 96.19 / lines 97.19 vs bars 95 / 86 / 95 / 96
  • pnpm lint and pnpm typecheck clean
  • Playwright e2e — 20/20 (CI=1 ROOTSCRIBE_E2E_PORT=45471, isolated from the live local instance on 44471)
  • README verification recipe validated against real signatures (valid / tampered body / wrong secret / stale / missing header)
  • Manual: set a secret in Settings, fire a test webhook at a receiver, verify with the README recipe

Notes for reviewers

  • The secret is write-only through the API (see above); users copy it at generation time. settings.json stays 0600.
  • The one-time "unsigned" warning lives in its own test file (post-unsigned-warning.test.ts) because the latch is per module instance and Vitest isolates modules per file.
  • Pre-existing flakiness in server/tests/routes/recordings.test.ts (and once a 503 in config.test.ts) showed up under full-suite load and passes on re-run; flagged separately, not touched here.

🤖 Generated with Claude Code

allenahner and others added 5 commits September 14, 2026 09:18
Every outbound delivery (fireRaw retries and the Settings "Test" path
alike) now carries `x-rootscribe-instance`. When a webhook secret is
configured it also carries `x-rootscribe-timestamp` and
`x-rootscribe-signature: t=<sec>,v1=<hex>` where v1 is the HMAC-SHA256
of `${t}.${body}` over the exact JSON string handed to fetch. The
timestamp is computed per attempt so a retry after backoff still lands
inside a receiver's tolerance window. Without a secret the server logs
once per process that deliveries are unsigned.

- shared: `AppConfig.instanceId: string | null` (default null).
- server/config: `ensureInstanceId()` mints + persists a UUID once;
  called at startup and by every delivery so the header is never absent.
- server/webhook/sign.ts: `signWebhook(secret, timestampSec, body)`.
- server/routes/config: PatchSchema accepts a trimmed, header-safe
  `instanceId` (1..128 chars of [A-Za-z0-9._:-]).

Tests: sign known-answer + sensitivity; ensureInstanceId persistence and
idempotency; signed/unsigned header matrix for fireRaw and testWebhook;
per-attempt re-signing across retries; log-once latch in its own file
for a fresh module instance; route validation for instanceId.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd wizard

Settings > Webhook Outbound gains a "Signing Secret" input with a
Generate button (32 CSPRNG bytes as 64 hex chars via
web/src/lib/webhookSecret.ts) and an editable "Instance ID" input. On
save the secret rides inside the webhook object — it is populated from
config on load so an unrelated edit re-sends it, since the server
replaces the whole webhook object per POST — and a blank instance id is
omitted so the server-minted value is kept rather than rejected.

The setup wizard's WebhookStep gets the same secret input + Generate
and a read-only instance id display (fetched via the config query) so
users can copy it into their receiver during onboarding.

appConfig factory: `withInstanceId()` trait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two journeys in tests/e2e/settings.spec.ts: the seeded instance id is
rendered in the Webhook section, and generating a secret + editing the
instance id then saving persists both — verified after reload in the UI
and via GET /api/config (a straight read of settings.json).

The e2e seed config pins `instanceId: "e2e-seed-instance"` so the
display is deterministic and /api/_test/reset restores it between
specs; fixtures.test.ts covers both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- New "Webhook signing" section: header table (instance / timestamp /
  signature), the `t=<sec>,v1=<hex>` format, the "sign the raw body
  bytes" rule, and a Node verification recipe with timing-safe compare
  and a replay-tolerance window sized for the 5s/30s retry backoff.
- Headers bullet lists X-RootScribe-Instance and the conditional
  signature headers; notes the X-RootScribe-Test marker on test sends.
- Payload example now includes `end_time_ms`, which the code has always
  sent.
- Config section mentions where the secret and instanceId live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per the repo's release convention, one version bump per PR against
main. New functionality (HMAC signing, instanceId, Settings/wizard
fields) makes this a minor bump under SemVer 2.0.0: 0.1.1 -> 0.2.0.

Same file set as the 0.1.1 hotfix bump (cb8ad5f / b3a32ff):
- five package.json files (root, server, web, shared, inbox-mcp)
- inbox-mcp/src/index.ts MCP server version metadata
- server/src/webhook/post.ts outbound User-Agent (now a single
  USER_AGENT constant shared by fireRaw and testWebhook)
- README.md documented User-Agent
- CHANGELOG.md [0.2.0] section + compare link

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Resolve malformed-config overwrite, protect webhook secrets from unauthenticated exposure, and ensure Test uses unsaved secrets.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds HMAC-SHA256 webhook signing, persistent instance IDs, UI configuration, tests, documentation, and the 0.2.0 release metadata.

Changes:

  • Signs webhook deliveries and retries with timestamped HMAC headers.
  • Adds instance ID generation, validation, persistence, and UI controls.
  • Updates tests, documentation, changelog, and package versions.
File summaries
File Summary
web/src/test-factories/appConfig.ts Adds instance ID factory support.
web/src/routes/setup/WebhookStep.tsx Adds wizard secret and instance ID UI.
web/src/routes/setup/WebhookStep.test.tsx Tests wizard signing controls.
web/src/routes/Settings.tsx Adds secret and instance ID settings.
web/src/routes/Settings.test.tsx Tests settings persistence and generation.
web/src/lib/webhookSecret.ts Generates secure webhook secrets.
web/src/lib/webhookSecret.test.ts Tests secret generation.
web/package.json Bumps web version.
tests/e2e/settings.spec.ts Covers settings persistence end-to-end.
shared/src/config.ts Extends shared configuration.
shared/src/config.test.ts Tests configuration defaults.
shared/package.json Bumps shared version.
server/tests/webhook/sign.test.ts Tests HMAC signing.
server/tests/webhook/post.test.ts Tests signed deliveries and retries.
server/tests/webhook/post-unsigned-warning.test.ts Tests unsigned warning behavior.
server/tests/routes/config.test.ts Tests instance ID validation.
server/src/webhook/sign.ts Implements HMAC signing.
server/src/webhook/post.ts Adds signed delivery headers.
server/src/test-seed/fixtures.ts Adds deterministic seed instance ID data.
server/src/test-seed/fixtures.test.ts Tests seed behavior.
server/src/routes/config.ts Validates and persists instance IDs.
server/src/index.ts Initializes instance IDs at startup.
server/src/config.ts Generates and persists instance IDs.
server/src/config.test.ts Tests instance ID generation.
server/package.json Bumps server version.
README.md Documents webhook signing and headers.
package.json Bumps root version.
inbox-mcp/src/index.ts Updates MCP version.
inbox-mcp/package.json Bumps MCP package version.
CHANGELOG.md Documents the 0.2.0 release.
Review details

Suppressed comments (1)

server/src/webhook/post.ts:43

  • This helper is also used by testWebhook, but it always reads the persisted loadConfig().webhook.secret. The new Settings/wizard secret is only persisted after Save/Next, so clicking Test immediately after Generate or editing the field sends an unsigned request or uses the old secret while the UI displays the new one. Pass the pending secret to the test endpoint, or disable Test until the settings are saved.
  const secret = loadConfig().webhook?.secret;
  • Files reviewed: 30/30 changed files
  • Comments generated: 5
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread server/src/config.ts Outdated
Comment thread server/src/index.ts
Comment thread shared/src/config.ts
Comment thread web/src/routes/Settings.tsx Outdated
Comment thread web/src/routes/setup/WebhookStep.tsx Outdated
…, no overwrite of corrupt config

Copilot round 1 on PR #19 raised three real issues; each is fixed
test-first.

1. Corrupt settings.json was overwritten at startup. loadConfig()'s
   catch branch serves DEFAULT_CONFIG, so the unconditional
   ensureInstanceId() persist would have replaced a hand-recoverable
   file with defaults + a UUID. config.ts now remembers a failed load
   and ensureInstanceId() mints the id in memory only (stable for the
   process, logged as a warning) until the file is repaired.

2. Signing secret leaked through GET /api/config. The API has no auth
   and Docker binds 0.0.0.0, so a LAN client could read the key and
   forge HMAC-valid deliveries. Responses now strip webhook.secret and
   report webhook.secretConfigured (mirrors the token redaction).
   POST webhook.secret is tri-state: omitted keeps the stored value,
   "" clears, non-empty replaces - so unrelated saves can no longer
   wipe a stored secret. Settings becomes a write-only draft with a
   configured-state placeholder and a Clear button; the wizard is
   unchanged in shape.

3. Test used the persisted secret, not the draft. POST
   /api/config/test-webhook accepts an optional `secret`; testWebhook
   (url, secret?) signs with it (undefined = stored, "" = unsigned).
   Settings and WebhookStep pass the field's current value so Test
   verifies what is about to be saved. Also closes the suppressed
   low-confidence finding on deliveryHeaders.

README: write-only secret, Test-uses-draft, corrupt-config behaviour.
CHANGELOG: 0.2.0 entries updated. e2e: Settings journey asserts the
secret is never echoed back and that an untouched field keeps it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two critical unresolved configuration issues can break webhook delivery and overwrite stored webhook settings.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 33/33 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread server/src/config.ts Outdated
Comment thread server/src/routes/config.ts Outdated
…al patches

Copilot round 2 on PR #19.

- ensureInstanceId() now applies the same header-safe rule as
  POST /api/config to the PERSISTED value. A hand-edited settings.json
  containing a control character (or an empty / over-long value) would
  otherwise be stamped verbatim into x-rootscribe-instance, where undici
  rejects the header and every delivery fails. Invalid values are
  replaced by a fresh UUID with a warning. The pattern lives once in
  @rootscribe/shared (INSTANCE_ID_PATTERN / isValidInstanceId) and the
  Zod schema reuses it.

- POST /api/config no longer touches `webhook` when the patch omits it.
  The normalizer always set the key, so `{ jiraBaseUrl }` (JiraStep,
  right after WebhookStep) or `{ pollIntervalMinutes }` spread
  `webhook: undefined` over the stored object and silently wiped the
  URL and the freshly saved secret. Pre-existing bug, but squarely in
  this feature's path; covered by a route test that saves a webhook +
  secret, then two partial patches, and asserts both survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical persisted-secret issue and three moderate review findings remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

server/src/routes/config.ts:124

  • Now that secret is part of this schema, a request with a valid URL but a non-string secret also reaches this branch and is reported as invalid URL. Return a generic invalid-body message or inspect the validation path so API consumers are not misled about which field failed.
const TestWebhookSchema = z.object({
  url: z.string().url(),
  secret: z.string().optional(),
});

web/src/routes/Settings.tsx:307

  • After a successful Test, changing the signing secret leaves the old result visible: the input, Generate, and Clear handlers never call setTestResult(null). The UI can therefore continue showing “Connection Success” for an unsigned/old-secret request after the draft has changed. Clear the test result whenever any secret mutation occurs.
                  setWebhookSecret(generateWebhookSecret());

web/src/routes/setup/WebhookStep.tsx:109

  • After a successful Test Connection, editing or regenerating the draft secret leaves the old success/failure result visible because those handlers never clear testResult. That result may describe an unsigned or old-secret request, so it is misleading once the value used for the next test has changed. Clear the result whenever the secret changes, including Generate.
  • Files reviewed: 33/33 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread server/src/webhook/post.ts
…rs, clear stale Test results

Copilot round 3 on PR #19 (one inline thread + three suppressed
low-confidence findings, all verified real):

- webhook/post.ts: only a non-empty STRING is used as the signing key.
  loadConfig() trusts settings.json's shape, so a hand-edited
  `"secret": 12345` would reach createHmac(), throw inside fireRaw's
  try, and retry the same broken delivery on every attempt. Non-string
  values now send unsigned with a one-time warning.
- routes/config.ts: POST /api/config/test-webhook's 400 names the field
  that failed ("invalid secret" / "invalid url") instead of a blanket
  "invalid URL" now that `secret` is in the schema.
- Settings.tsx / WebhookStep.tsx: editing, generating, or clearing the
  secret discards a prior Test result, which described a different
  secret and could read "Connection Success" for a value the receiver
  cannot verify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Address the retry-secret capture, instance ID validation, and setup-wizard secret handling findings before approval.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

web/src/routes/setup/WebhookStep.tsx:107

  • This placeholder promises that leaving the field blank sends unsigned, but that is false when the wizard is resumed after a partial setup with a stored secret: saveAndContinue omits secret and the route deliberately preserves the stored value, while Test also passes undefined and uses it. Because setup can be left before completeSetup, add an explicit clear path here or change the copy to say the existing secret is kept.

server/src/webhook/post.ts:172

  • This re-reads the persisted secret for every retry. If an event gets a 503 and the operator rotates or clears the secret before the 5s/30s retry, the original URL receives that retry signed with a different key and rejects it, turning a transient failure into a permanent delivery failure. Capture the secret once for the delivery while still recomputing the timestamp/signature per attempt.
        headers: deliveryHeaders(event, body, loadConfig().webhook?.secret),
  • Files reviewed: 33/33 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread shared/src/config.ts
…y on resume

Copilot round 4 on PR #19.

- webhook/post.ts: fireRaw reads the signing secret ONCE per delivery
  and reuses it for every retry attempt (timestamp + signature are
  still recomputed per attempt). Re-reading on each attempt meant a
  secret rotated in Settings during the 5s/30s backoff re-signed the
  retry with the new key, which the original receiver rejects - a
  transient 503 became a permanent delivery failure. Test rotates the
  secret between attempt 1 and 2 and asserts both verify with the
  original key.
- WebhookStep: a wizard resumed after a partial setup may already hold
  a stored secret, and Next / Test Connection both keep it when the
  field is blank; the placeholder now says so instead of promising
  "unsigned". Reads `webhook.secretConfigured` from the config query
  the step already makes.
- shared/config.test.ts: regression guard that isValidInstanceId()
  rejects trailing line terminators. The reviewed claim that `$`
  matches before a final newline is Perl/Python behaviour, not
  JavaScript's (non-multiline `$` anchors at end of input only) - the
  test pins that so a future `m` flag or rewrite can't reopen it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved persistence, secret-status, and setup-wizard data-loss issues must be fixed before approval.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

server/src/routes/config.ts:24

  • usableSecret() treats a non-string persisted secret as unusable and sends deliveries unsigned, but this response flag marks any truthy value as configured. With a hand-edited webhook.secret: 12345 (the delivery path explicitly handles this case), Settings will show “configured” even though no signature is emitted, which can make the operator believe verification is active. Base secretConfigured on the same non-empty-string check.
    ? (({ secret, ...rest }) => ({ ...rest, secretConfigured: Boolean(secret) }))(cfg.webhook)
  • Files reviewed: 33/33 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread server/src/config.ts Outdated
Comment thread web/src/routes/setup/WebhookStep.tsx
… hydrate wizard URL

Copilot round 5 on PR #19.

- config.ts: ensureInstanceId() catches a saveConfig() failure
  (read-only settings.json, full disk) and keeps the generated id in
  memory for the process with an error log, instead of throwing before
  listen() at startup or inside fireRaw's try on every delivery. Test
  chmods settings.json 0400 (skipped when running as root, where chmod
  is bypassed) and asserts no throw, a stable id, and an untouched file.
- routes/config.ts: `secretConfigured` uses the same non-empty-string
  rule as the delivery path's usableSecret(), so a hand-edited
  non-string secret is not reported as configured while deliveries go
  out unsigned.
- WebhookStep: hydrates the URL from a stored webhook once (guarded by
  a touched ref so a typed value is never clobbered), so revisiting the
  step offers Next rather than a Skip that would send webhook=null and
  delete the stored URL and secret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The setup wizard can clear stored webhook data while its config query is loading or has failed, and the config route has a lint error.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

server/src/routes/config.ts:27

  • This destructured secret binding is intentionally discarded, but the repository's ESLint rule only ignores unused variables whose names start with _ (eslint.config.js:41-44). As written, pnpm lint reports secret as unused. Rename the binding while preserving the property omission (for example, destructure secret: _secret).
    ? (({ secret, ...rest }) => ({

shared/src/config.ts:19

  • This comment misstates the contract: PatchSchema explicitly accepts webhook.secret and the route persists it, while secretConfigured is the response-only field that Zod drops from incoming patches. Keeping the comment as written can lead future changes to remove the write path or incorrectly treat the secret as non-persistent.
  // Response-only: whether a secret is stored. The server strips it from
  // incoming patches (Zod drops unknown keys) and never persists it.
  secretConfigured?: boolean;
  • Files reviewed: 33/33 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread web/src/routes/setup/WebhookStep.tsx
…known stored state

Copilot round 6 on PR #19.

- WebhookStep: the primary button is disabled while the config query is
  pending, and a blank-URL Skip only posts webhook=null when the stored
  state is KNOWN (query succeeded) or the user deliberately cleared a
  hydrated URL. If the query failed, Skip proceeds without touching the
  stored webhook + secret. Existing click-driven tests wait for the
  button to enable.
- routes/config.ts: the discarded destructured secret is bound as
  `storedSecret` and used for the configured check, so the omission is
  explicit (lint was already passing; this removes the ambiguity).
- shared/config.ts: reword the `secretConfigured` comment so it cannot
  be read as "the secret is never persisted" - the secret IS accepted
  and stored on POST; only the response flag is dropped from patches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Three moderate findings remain around stale settings saves and secret-only webhook Test invalidation.

Review details

Suppressed comments (3)

web/src/routes/Settings.tsx:105

  • This save always includes the current webhook value, but the form can be hydrated from stale React Query data while /api/config is refetching (main.tsx:13). If the cache says webhook: null and the user edits only the instance ID or poll interval before the refetch completes, this posts webhook: null and clears the server's current URL and signing secret. Disable the form/save until the config fetch settles, or omit webhook when none of its fields were edited.
      await api.updateConfig({
        webhook: webhookUrl.trim()
          ? {
              url: webhookUrl.trim(),
              enabled: true,

web/src/routes/Settings.tsx:64

  • The dirty guard prevents this hydration effect from running while any Settings field is being edited, and the invalidation check only compares URL and instance ID. If the stored signing secret changes during an in-flight Test (for example, another config update while the user is editing an unrelated field), a blank draft still means “use the stored secret,” but the old response is accepted and shown as verification of the new secret. Invalidate the test generation on every config replacement or otherwise track the redacted secret state/gate Test while it is being refetched.
    if (!cfg.data || dirty) return;
    const c = cfg.data.config;
    // Server data is replacing the draft. If anything a Test stamps (URL,
    // instance id) actually changes, an in-flight Test described the old
    // values and must not be rendered as success for the new ones.

web/src/routes/setup/WebhookStep.tsx:41

  • A config refetch that changes only the persisted signing secret does not invalidate an in-flight Test: when the URL is untouched, stored === url skips invalidateTest(), and when the URL was typed, the earlier guard returns before this check. With a blank draft, the request uses the stored secret, so a response for the old key can still be rendered as success after rotation. Invalidate the test generation for any config replacement, not just a URL change.
  • Files reviewed: 33/33 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…dates an in-flight Test

Copilot round 18 on PR #19 (suppressed low-confidence findings).

- Settings: `webhook` is included in the save only after the URL or
  secret was edited this session (webhookTouched). The server keeps the
  stored webhook when the key is omitted, so an unrelated save (poll
  interval, instance id) can no longer re-post a stale cached value -
  including null - over the server's current URL + secret.
- Settings + WebhookStep: an in-flight Test is invalidated whenever a
  config refetch completes (cfg.dataUpdatedAt), not only when the URL
  or instance id visibly changed. A secret rotation made elsewhere is
  invisible in the redacted response, so a blank-draft test signed with
  the old key could otherwise render as success after the rotation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

One or more issues must be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

server/tests/webhook/post.test.ts:620

  • This retry test validates each HMAC against that attempt's own timestamp, but never asserts that timestamps/signatures are refreshed between attempts. Since the suite leaves Date.now() real and only advances timers, all attempts can share one second; a regression that computed the headers once before the retry loop would still pass. Advance the clock per retry and assert the later timestamp/signature changes.
    for (const call of fetchMock.mock.calls) {
      const init = call[1] as RequestInit;
      const headers = init.headers as Record<string, string>;
      const { t, v1 } = parseSignature(headers["x-rootscribe-signature"]!);
      expect(v1).toBe(expectedSignature(secret, t, String(init.body)));
  • Files reviewed: 33/33 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread web/src/routes/Settings.tsx Outdated
Comment thread web/src/routes/Settings.tsx Outdated
…elds, save only edited ones

Copilot round 19 on PR #19. Two inline findings and a suppressed one,
all in the "another client changed config underneath this draft"
family. Rather than add another special case, replace the page-wide
`dirty` gate with per-field touched tracking, which closes the family:

- Hydration runs on every config arrival and re-hydrates each field the
  user has NOT edited (URL, instance id, poll, Jira; the write-only
  secret draft is never hydrated). Touched drafts are left alone, so a
  refetch can no longer leave an untouched URL stale next to an edited
  secret.
- Save sends only touched fields. The server keeps everything it does
  not receive, so an unrelated save can no longer overwrite an instance
  id / webhook / interval another client changed with this page's stale
  copy. `dirty` is now derived (any field touched) and cleared after a
  successful save, at which point the refetched data re-hydrates.

Tests: untouched instance id is never re-posted (poll-only save sends
exactly { pollIntervalMinutes }); a refetch re-hydrates untouched URL
and instance id while a typed secret survives and the save pairs the
NEW URL with it; existing save tests adjusted to edit the fields they
assert on. Server: a retry test that fakes Date as well as timers and
asserts each attempt's timestamp advances by the 5 s / 30 s backoff
with a distinct signature, so a headers-computed-once regression
cannot pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Settings Save and Test can use stale cached webhook configuration after a failed refetch.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

web/src/routes/Settings.tsx:133

  • When the config query has cached data, React Query can keep that data while a refetch is in flight or after it fails. This save path is still enabled and, after only the secret is edited, combines the draft secret with the cached webhook URL; if another client removed or changed the webhook, this can resurrect or rewire it. Gate Settings Save (and Test) on a successful, non-fetching config query or otherwise require a fresh/re-entered URL, and cover the cached-refetch-error case.

This issue also appears on line 309 of the same file.

web/src/routes/Settings.tsx:309

  • This Test button remains enabled while the shared config query is refetching or has errored with cached data. In that window test() passes the locally hydrated instanceId (and an omitted draft secret is resolved against the server), so a stale instance id can be tested even though a subsequent Save would omit it and use the current server value; a fast response can render a misleading success before dataUpdatedAt invalidates it. Gate Test on a successful, non-fetching config query as WebhookStep does.
              disabled={!webhookUrl || saving}
  • Files reviewed: 33/33 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…query

React Query keeps the last good config while a refetch is in flight or
after one fails, so the form still rendered and Save/Test stayed enabled
against possibly-stale hydrated values: a secret-only Save re-posted the
cached webhook URL (resurrecting or rewiring a webhook another client
removed or changed) and Test sent the cached instance id. Both controls
now wait for `cfg.isSuccess && !cfg.isFetching` — the same gate
WebhookStep already uses — and the footer explains the disabled state
when the refetch errored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Two moderate findings (one vote each) remain because secret-only edits in Settings and setup can re-enable disabled webhooks.

Review details

Suppressed comments (2)

web/src/routes/Settings.tsx:144

  • A secret-only edit can target a webhook that is currently disabled, but this payload unconditionally writes enabled: true. Rotating or clearing the signing secret would therefore start deliveries unexpectedly for a stored { url, enabled: false } config; preserve the stored enabled value when webhookUrl was not touched and only enable when the URL is explicitly configured/changed.
              webhook: webhookUrl.trim()
                ? {
                    url: webhookUrl.trim(),
                    enabled: true,
                    // Tri-state on the wire: omitted = keep stored, "" =

web/src/routes/setup/WebhookStep.tsx:125

  • When this step is revisited with a stored webhook whose enabled flag is false, clicking Next (for example after only entering a secret) rewrites it as enabled: true and starts deliveries without an explicit URL change. Preserve the stored enabled state for an untouched hydrated URL, while keeping true for a newly entered URL.
  • Files reviewed: 33/33 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…cret changes

Settings Save and the wizard's Next both wrote `enabled: true` whenever a
URL was present, so rotating or clearing the signing secret on a webhook
stored as `{ url, enabled: false }` silently started deliveries. The
server defaults an omitted `enabled` to "URL present", so the stored flag
is now echoed back explicitly for a hydrated (untouched) URL; a URL the
user edited or typed is still an explicit "turn it on".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Three unresolved moderate issues affect stale configuration state and test-result invalidation during refetches.

Review details

Suppressed comments (3)

web/src/routes/Settings.tsx:162

  • If the POST succeeds but the follow-up invalidateQueries refetch fails, React Query keeps the old cached config and marks the query errored; this code still clears touched. The hydration effect then rehydrates the form from that stale cache, so the newly saved URL/secret/instance ID disappears from the UI and secretConfigured can incorrectly revert until a reload. Only clear touched after confirming the config query has a successful refreshed state, or seed the query cache from the successful POST response before clearing the drafts.
      await qc.invalidateQueries({ queryKey: ["config"] });
      setTouched(NOTHING_TOUCHED);

web/src/routes/Settings.tsx:106

  • dataUpdatedAt changes only when a refetch returns new data. If a config refetch is still in flight or finishes with an error, this effect never invalidates the generation, so an already-running Test can resolve afterward and display success for cached/unknown secret and instance values even though configSettled is false. Invalidate when cfg.isFetching starts (or on every refetch outcome) so no result survives a config refresh.
  useEffect(() => {
    if (cfg.dataUpdatedAt) invalidateTest();
    // invalidateTest is stable in effect (bumps a ref, clears state).
  }, [cfg.dataUpdatedAt]);

web/src/routes/setup/WebhookStep.tsx:45

  • dataUpdatedAt changes only when a refetch returns new data. If a config refetch is still in flight or finishes with an error, this effect never invalidates the generation, so an already-running Test Connection can resolve afterward and display success for cached/unknown secret and instance values even though the button is gated by cfg.isFetching/cfg.isError. Invalidate when cfg.isFetching starts (or on every refetch outcome) so no result survives a config refresh.
  • Files reviewed: 33/33 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…st when a refetch starts

Two React Query lifecycle gaps on the config query:

- Settings cleared the per-field drafts after `invalidateQueries`, so if
  the post-save refetch failed the hydration effect re-populated the form
  from the PRE-save cache and the values just saved vanished until a
  reload. The cache is now seeded from the POST response before the
  drafts are cleared; a failed refetch keeps the seeded data.

- The Test / Test Connection result was only discarded on `dataUpdatedAt`,
  which moves solely on a successful refetch. A test already in flight
  when a refetch began could land "success" against config being
  replaced, and an errored refetch never cleared it. Both pages now
  invalidate the test generation when `isFetching` starts as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Resolve the two moderate webhook/config findings and the shared response-type mismatch before approval.

Review details

Suppressed comments (3)

server/src/routes/config.ts:44

  • redactForClient maps an omitted enabled to false, but the POST normalizer treats an omitted flag as url.length > 0. A persisted/hand-edited { url: "https://..." } therefore appears disabled to Settings, and a secret-only save echoes that false value and leaves the webhook disabled instead of applying the documented default. Preserve an explicit boolean and use the same URL-present default when it is missing.
          enabled: Boolean(enabled),

server/src/webhook/post.ts:180

  • The retry loop captures the signing secret once, but it does not capture the instance id: deliveryHeaders() calls ensureInstanceId() again on every attempt. If Settings changes the instance id during the 5s/30s backoff, the same logical delivery is sent first with the old identity and then with the new one, so receivers that use (instance,event) for attribution or deduplication can treat the retry as coming from a different install. Capture ensureInstanceId() once per fireRaw delivery and pass that value to every attempt, just like the secret.
  const secret = loadConfig().webhook?.secret;
  for (let attempt = 0; attempt < BACKOFF_MS.length; attempt++) {
    const started = Date.now();
    try {
      const res = await fetch(url, {
        method: "POST",
        headers: deliveryHeaders(event, body, secret),

shared/src/config.ts:21

  • ConfigResponse still aliases AppConfig, so adding secret?: string here makes the shared response type advertise that GET/POST config may contain the secret even though redactForClient() deliberately strips it. A future TypeScript client can legally read config.webhook.secret and silently get undefined, undermining the write-only contract. Split the persisted/patch webhook shape from the redacted response shape (or otherwise omit secret from ConfigResponse) while retaining it in the POST patch type.
  secret?: string;
  // Response-only flag: true when a non-empty string secret is stored. The
  // secret itself IS accepted and persisted on POST (see above); this flag
  // is the read side. PatchSchema does not list `secretConfigured`, so Zod
  // drops it if a client echoes it back — it is never persisted.
  secretConfigured?: boolean;
  • Files reviewed: 33/33 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…nfig response type

- fireRaw captured the signing secret once per delivery but re-read the
  instance id on every attempt, so an id changed in Settings during the
  5s/30s backoff sent the same logical delivery under two identities. The
  id is now captured alongside the secret and passed to every attempt.

- `ConfigResponse` aliased `AppConfig`, so adding `secret?` to the
  persisted webhook shape made the shared RESPONSE type advertise a field
  `redactForClient()` strips. `WebhookConfigResponse` / `AppConfigResponse`
  now narrow the read side (`secret` omitted, `secretConfigured` kept);
  the POST patch and on-disk shapes are unchanged. A compile-time test
  pins the split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved moderate findings affect webhook configuration consistency, stale URL preservation, and wizard cache state.

Review details

Suppressed comments (4)

server/src/routes/config.ts:44

  • This coercion turns a missing or invalid persisted enabled value into false, while the POST normalizer below treats an omitted flag as url.length > 0. For a hand-edited but otherwise usable webhook such as { url, secret }, the API therefore reports it disabled; editing only the secret/instance in Settings or revisiting the wizard echoes enabled: false and can stop deliveries. Preserve boolean values and apply the same URL-present default for missing/invalid values.
          enabled: Boolean(enabled),

web/src/routes/Settings.tsx:158

  • A secret-only save still sends the hydrated URL and enabled inside webhook. If another Settings tab/client changes the webhook URL after this page's last config fetch (before React Query starts a refetch), this request overwrites that newer URL with the stale one; the per-field tracking cannot prevent it because webhook is present in the patch. Add optimistic version/conflict handling or a server-side secret-only patch that preserves the current URL.
    web/src/routes/setup/WebhookStep.tsx:139
  • This invalidation happens after a successful save, but unlike Settings it does not seed ['config'] with the POST response. If the follow-up GET fails, React Query retains the pre-save cached config (for example webhook: null), so navigating back to this step shows stale URL/secret state despite the server having saved the draft (and may leave the user at Skip/unable to test). Capture the updateConfig response and call setQueryData before invalidating, as Settings does.
    web/src/routes/setup/WebhookStep.tsx:134
  • When a resumed wizard only adds/rotates a secret, this request still includes the hydrated URL and enabled flag. If another client changes that URL after the wizard's last config fetch, the save overwrites the newer URL with stale state; urlTouched only controls the enabled default, not whether the stale URL is sent. Use a conflict/version check or a patch that updates the secret without replaying the URL.
  • Files reviewed: 34/34 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…e invalidating

WebhookStep.persistDraft awaited the POST and discarded its response,
unlike Settings, which seeds ['config'] from it before invalidating. If
the wizard's post-save refetch failed, React Query kept the PRE-save
config (webhook: null), so Back remounted the step showing Skip — which
would post null over the webhook the server had just stored.

Capture the updateConfig response in both branches and setQueryData
before invalidateQueries, matching Settings.

Copilot review on PR #19 round 24 (suppressed finding, WebhookStep.tsx:139).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@allenahner
allenahner requested a lite review from Copilot September 17, 2026 16:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

server/src/routes/config.ts currently coerces webhook.enabled with Boolean(...), which can mis-report a hand-edited non-boolean value (e.g. "false") as enabled and should be sanitized more strictly.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

server/src/routes/config.ts:47

  • redactForClient() coerces enabled with Boolean(enabled), which will treat any truthy non-boolean (e.g. a hand-edited string "false") as enabled. Since this endpoint is explicitly defending against hand-edited/invalid persisted shapes, it’s safer to accept only a real boolean and default everything else to false (so the UI doesn’t show an enabled webhook that may not be intentionally enabled).
  • Files reviewed: 34/34 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

loadConfig() type-asserts settings.json rather than validating it, so
`webhook.enabled` can hold any shape. Both readers treated it loosely:
redactForClient() coerced with Boolean(enabled) and the delivery gate
tested `!cfg.webhook.enabled`. A hand-edited `"enabled": "false"` is a
truthy string, so it read as enabled AND fired deliveries the user
plainly meant to disable.

Tighten both to `=== true`. They have to move together: the two sites
share one invariant, and tightening redactForClient() alone would make
Settings display "disabled" while the dispatcher kept firing — swapping
a permissive-but-consistent state for an inconsistent one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The broad cross-layer security, persistence, UI, and release changes warrant final human review.

Review effort: Lite
Findings: None

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants