Repository navigation
DEVX-1016: Sign Rootscribe outbound webhooks with HMAC-SHA256 and send timestamp and instance-id headers - #19
Conversation
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>
There was a problem hiding this comment.
🟡 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 persistedloadConfig().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.
…, 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>
There was a problem hiding this comment.
🟡 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
…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>
There was a problem hiding this comment.
🟡 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
secretis part of this schema, a request with a valid URL but a non-stringsecretalso reaches this branch and is reported asinvalid 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
…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>
There was a problem hiding this comment.
🟡 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
secretand the route deliberately preserves the stored value, while Test also passesundefinedand uses it. Because setup can be left beforecompleteSetup, 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
…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>
There was a problem hiding this comment.
🟡 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-editedwebhook.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. BasesecretConfiguredon 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
… 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>
There was a problem hiding this comment.
🟡 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
secretbinding 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 lintreportssecretas unused. Rename the binding while preserving the property omission (for example, destructuresecret: _secret).
? (({ secret, ...rest }) => ({
shared/src/config.ts:19
- This comment misstates the contract:
PatchSchemaexplicitly acceptswebhook.secretand the route persists it, whilesecretConfiguredis 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
…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>
There was a problem hiding this comment.
🔵 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
webhookvalue, but the form can be hydrated from stale React Query data while/api/configis refetching (main.tsx:13). If the cache sayswebhook: nulland the user edits only the instance ID or poll interval before the refetch completes, this postswebhook: nulland clears the server's current URL and signing secret. Disable the form/save until the config fetch settles, or omitwebhookwhen none of its fields were edited.
await api.updateConfig({
webhook: webhookUrl.trim()
? {
url: webhookUrl.trim(),
enabled: true,
web/src/routes/Settings.tsx:64
- The
dirtyguard 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 === urlskipsinvalidateTest(), 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>
There was a problem hiding this comment.
🟡 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
…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>
There was a problem hiding this comment.
🔵 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 hydratedinstanceId(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 beforedataUpdatedAtinvalidates 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>
There was a problem hiding this comment.
🔵 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 whenwebhookUrlwas 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
enabledflag is false, clicking Next (for example after only entering a secret) rewrites it asenabled: trueand starts deliveries without an explicit URL change. Preserve the stored enabled state for an untouched hydrated URL, while keepingtruefor 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>
There was a problem hiding this comment.
🔵 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
invalidateQueriesrefetch fails, React Query keeps the old cached config and marks the query errored; this code still clearstouched. The hydration effect then rehydrates the form from that stale cache, so the newly saved URL/secret/instance ID disappears from the UI andsecretConfiguredcan incorrectly revert until a reload. Only cleartouchedafter 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
dataUpdatedAtchanges 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 thoughconfigSettledis false. Invalidate whencfg.isFetchingstarts (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
dataUpdatedAtchanges 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 bycfg.isFetching/cfg.isError. Invalidate whencfg.isFetchingstarts (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>
There was a problem hiding this comment.
🔵 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
redactForClientmaps an omittedenabledtofalse, but the POST normalizer treats an omitted flag asurl.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()callsensureInstanceId()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. CaptureensureInstanceId()once perfireRawdelivery 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
ConfigResponsestill aliasesAppConfig, so addingsecret?: stringhere makes the shared response type advertise that GET/POST config may contain the secret even thoughredactForClient()deliberately strips it. A future TypeScript client can legally readconfig.webhook.secretand silently getundefined, undermining the write-only contract. Split the persisted/patch webhook shape from the redacted response shape (or otherwise omitsecretfromConfigResponse) 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>
There was a problem hiding this comment.
🔵 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
enabledvalue intofalse, while the POST normalizer below treats an omitted flag asurl.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 echoesenabled: falseand 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
enabledinsidewebhook. 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 becausewebhookis 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
Settingsit does not seed['config']with the POST response. If the follow-up GET fails, React Query retains the pre-save cached config (for examplewebhook: null), so navigating back to this step shows stale URL/secret state despite the server having saved the draft (and may leave the user atSkip/unable to test). Capture theupdateConfigresponse and callsetQueryDatabefore invalidating, asSettingsdoes.
web/src/routes/setup/WebhookStep.tsx:134 - When a resumed wizard only adds/rotates a secret, this request still includes the hydrated URL and
enabledflag. If another client changes that URL after the wizard's last config fetch, the save overwrites the newer URL with stale state;urlTouchedonly 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>
There was a problem hiding this comment.
🔵 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()coercesenabledwithBoolean(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 tofalse(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>
Summary
Outbound webhooks can now be verified by their receivers, and receivers can tell RootScribe installs apart.
fireRawretries and the Settings/wizard "Test" send alike — carriesx-rootscribe-timestamp(Unix seconds) andx-rootscribe-signature: t=<sec>,v1=<hex>, wherev1is 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).AppConfig.instanceId(defaultnull), minted as a UUID on first run byensureInstanceId()(called at startup and by every delivery so the header is never absent even ifsettings.jsonwas hand-edited). Sent asx-rootscribe-instanceon every delivery, signed or not.POST /api/configaccepts 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.GET/POST /api/confignever returnwebhook.secret— the API has no auth and Docker binds0.0.0.0, so a LAN client could otherwise forge HMAC-valid deliveries. Responses carrywebhook.secretConfiguredinstead (mirrors thetokenredaction). OnPOST,webhook.secretis tri-state: omitted = keep the stored value,""= clear, non-empty = replace — so unrelated saves can't wipe a stored secret.WebhookStepgets the same secret input + Generate and a read-only instance-id display via the config query.POST /api/config/test-webhookaccepts an optionalsecret;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.ensureInstanceId()mints in-memory only whensettings.jsonexists but failed to parse, so startup can't clobber a hand-recoverable file with defaults + a UUID.timingSafeEqualand a replay window; the recipe was exercised against realsignWebhookoutput). Headers bullet updated. Payload example now includesend_time_ms, which the code has always sent.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 inpost.tsare consolidated into oneUSER_AGENTconstant.Related Issues
maindirectly and carries the version bump)Acceptance criteria → evidence
x-rootscribe-signaturewithtandv1,v1verifies against the bodyserver/tests/webhook/post.test.ts"signed delivery…" + "re-signs every retry attempt…" (recomputes the HMAC in the test, no mocks)post.test.ts"always sends x-rootscribe-instance…", "no secret configured…", "mints and persists an instance id on the fly…"post.test.ts"testWebhook — signing + instance headers"tests/e2e/settings.spec.ts(UI reload +GET /api/config),server/tests/routes/config.test.tsinstanceId cases,Settings.test.tsxsave-payload casesTest Plan
pnpm test830/830 (repeated clean full-suite runs)pnpm lintandpnpm typecheckcleanCI=1 ROOTSCRIBE_E2E_PORT=45471, isolated from the live local instance on 44471)Notes for reviewers
settings.jsonstays0600.post-unsigned-warning.test.ts) because the latch is per module instance and Vitest isolates modules per file.server/tests/routes/recordings.test.ts(and once a 503 inconfig.test.ts) showed up under full-suite load and passes on re-run; flagged separately, not touched here.🤖 Generated with Claude Code