Repository navigation
feat(connectors): the catalogue half renders a message only when a writer declared it readable (TASK-131) - #1881
Merged
Merged
Conversation
…er declared it readable `V2ConnectorsPage`'s catalogue branch was `installation.errorMessage || <generic>`, and `InstallableInstallation.errorMessage` has two kinds of writer: the reconciler's human-authored constants and `markProjectionFailure`, which stores the projector's own exception text (`connect ECONNREFUSED <addr>:443`). So a raw exception was printed in a stranger's connector row — disclosure, not diagnosis (the same class #1878 fixed on the pod-scoped half, measured and disclosed rather than folded in there). #1878's shape, carried across: `errorMessageUserFacing`, written together with the message by one owner so no writer can set half. - `userFacingInstallationError(reason)` / `diagnosticInstallationError(error)` are the two ways the field gets a value; the reconciler's two `$set`s and `markProjectionFailure` now take the builder instead of assembling the pair by hand. - `InstallableInstallation.errorMessageUserFacing` (Boolean, default false). - `installableCatalogService.publicInstallation` hands the flag over with the message, so the page never infers provenance from the shape of the string. - The page gates the catalogue branch the way it already gates the pod-scoped one: `errorMessageUserFacing && errorMessage`, else the generic sentence. Witnesses: - a real projection failure stores `errorMessageUserFacing: false` (the raw exception is the writer that must say so); - the reconciler's stale-lock and missing-projection sweeps store `true`, read back from the database after `sweep()`; - the mapper carries `true` and `false` through, with the message still present in both cases — the page, not the mapper, decides what to show; - the page renders the generic sentence for an unflagged catalogue failure and the message verbatim when the flag is set. The pre-existing catalogue fixture that asserted `projection missing` verbatim now carries the flag its writer sets, which is the contract change rather than a relaxed assertion. Scope note: `components[].errorMessage` is left alone — the mapper carries it but no reader renders it (the page reads component `status` only), so a flag there would be a contract with no consumer. TASK-131.
…d of omitting it (wren 73858) A `$set` that leaves a key out inherits the row's stored value, so `externalFeedService`'s error write was only safe on a row that had never carried a flag: a row that had been flipped by a permanent delivery failure kept `errorMessageUserFacing: true`, and this writer's provider text or raw exception would then render under that earlier provenance. It now says `false` explicitly, and its comment says why rather than claiming the flag "stays unset". The delivery-failure service's docstring gains the fallback line: the flag is stated, not defaulted, so a writer that omits it is making a claim it cannot make. Witness: the sync-failure test now asserts the `$set` names the flag, beside the status and message it already asserted. Reachability is contract-tightening, not a live disclosure (wren 73858): the only writer of `true` runs on the Telegram and Slack flip paths, which need a bound `config.chatId`, and no `x`/`instagram` row can have one. TASK-131.
…t to do (TASK-131)
The mechanism in this PR is what makes these two strings user-facing: the page now
prints `errorMessage` verbatim whenever a writer declares it readable, and the only
two writers that declare it are the reconciler's constants. Leaving them as
`'install lock expired'` and `'projection missing'` would put the lock lifecycle
vocabulary in front of the person whose connector failed (vera 73913, wren 73914).
- `installableReconciler:61` -> "Setup was interrupted before it finished. Try again."
- `installableReconciler:90` -> "This connector's channel is gone. Retry to rebuild it."
The second is not wren's 73914 wording ("Setup didn't finish in this pod. Try
again."), and the difference is vera 73915's constraint rather than taste: the
page's generic fallback is "Setup didn't finish.", so a flagged reason that reads
as a variation on it makes the evidence pair - and the UI - say the same thing in
two states. Her adjustment clause ("adjust the wording if a string reads wrong in
context") covers this, and the event it names is genuinely different: the connector
existed and its channel record vanished, which is why the row offers Retry. Both
strings stay free of internals - no lock, no projection, no step name.
The copy is pinned where it is produced: the stale-lock sweep's witness now asserts
the exact string, and the missing-projection witness already asserted its own, so
either constant moving is a red test rather than a silent UI change.
Every consumer of both strings was swept, not just the ones with assertions on
them (vera 73915 listed 4 assertions and 5 doc references; the string appears in 13
places):
- tests: 3 fixtures in `installableCatalogService.test.js`, 1 assertion in
`installableInstallationService.test.js`, 5 sites in `V2ConnectorsPage.test.tsx`
(the page test now names the reason once as `REASON_CHANNEL_GONE`, so the copy
lives in one line of that file);
- plans: `d8-phase-2-gate-surface.md` (`:9`, `:36`, `:65` as vera listed, plus
`:104` and `:117` which also quote the value) and
`connector-as-installable-app.md` (`:310`, `:423`).
No functional reader of either string exists - neither is a discriminator in a
query or a branch - so this is a pure copy change. Verified: backend 30 tests / 2
suites, frontend 45 tests / 1 suite.
TASK-131.
…(TASK-131)
Captured with `scripts/ui-evidence-shot.mjs` against the harness in
`docs/runbooks/local-ui-render-harness.md`. After side: this branch's worktree
(backend 5050, vite 3000) — `06391f11`'s page plus the working-tree copy commit
that is now `70fcaece`. Before side: `a2913955`, the revision this PR is cut from
(backend 5051, vite 3001). Same database, same token, and the pair for each width
was captured back to back so the row's relative age line does not differ between
the two.
The subject is a catalogue installation in the error state. Two rows, so one frame
carries both flag states:
- Slack, `errorMessageUserFacing: true` -> "This connector's channel is gone.
Retry to rebuild it." in both revisions (a flagged message is already verbatim;
this is the "user-facing reason when the flag is set" frame).
- Telegram, `errorMessageUserFacing: false`, `errorMessage` = the raw
`connect ECONNREFUSED 10.4.4.7:443` -> before: printed in the row (the
disclosure this PR fixes); after: "Setup didn't finish."
`diff` of the two `document.body.innerText` dumps at each width is that one line
and nothing else, which is why the `.txt` pair ships beside the PNGs — a raster is
the illustration, the text is the evidence:
14c14
< connect ECONNREFUSED 10.4.4.7:443
---
> Setup didn't finish.
Measured, not assumed: the phone width does render the row's line (the `#1757`
caveat, where `v2.css` hides a row's detail at 390, does not apply here), and both
revisions reported `non-2xx: none` and `console errors: none`.
Harness notes, because they decide whether the frame is reachable at all: the
provider readiness gate renders "Not enabled on this instance." *instead of* the
error state, so the after backend boots with the dummy `TELEGRAM_*` / `SLACK_*` /
`CONNECTOR_SECRET_*` values a local instance needs; the installation rows are
seeded directly (the install route refuses with `provider_not_configured` before
those values exist), and the flagged row's string is the reconciler's own constant,
not a string this harness produced. Source blobs are untouched by this commit.
lilyshen0722
added a commit
to thantiklermcirony/commonly
that referenced
this pull request
Oct 9, 2026
…Id, kind) (TASK-124 part 2) (Team-Commonly#1898) * feat(discord): encrypt the webhook URL at rest, keyed by (integrationId, kind) (TASK-124 part 2) `discord_integrations.webhookUrl` was the last connector credential stored in plaintext outside the ESO path. The URL embeds the webhook's own token, so holding it is posting AS that channel. Writers now put it in the connector-secret envelope (AES-256-GCM, ring from CONNECTOR_SECRET_KEYS) and the row keeps only an opaque ref at `Integration.config.webhookUrlRef`; readers go through `utils/discordWebhookUrl`. Wren's ruling: option A, keyed with `kind`. The same change fixes the three blockers the design exposed, which is what makes the encryption safe to turn on rather than a new way to lose a live credential: 1. `sweepOrphanedConnectorSecrets` kept a secret only on a Slack `botTokenRef`/`pendingBind.botTokenRef` pair, so it revoked a Discord secret ten minutes after it was written (`ORPHAN_SECRET_GRACE_MS`). Its keep-condition is now drawn from the kinds registry. 2. `sweepUnavailableSlackSecretKeys` marked ANY row with a Slack-named reason, and since Team-Commonly#1878/Team-Commonly#1881 that string is user-facing — a Discord owner read "Slack connector secret key is unavailable". Renamed and made kind-aware; Slack's sentence is byte-identical. 3. `upsertEncrypted` filtered on `{ integrationId }` alone, on both the upsert and the 11000 retry, so a second kind did not collide: it MATCHED the first kind's row and $set the new ciphertext onto it, leaving the first ref valid and decrypting to the second secret. The filter now carries `kind`, the model declares ONE index on the pair, and `services/connectorSecretKinds` is the single definition all four sites read. Also: `webhookUrl` and `webhookUrlRef` join `SERVER_OWNED_CONFIG_KEYS` (the URL is derived by the server on both writers, and a planted ref would point a row at another row's secret), and `webhookUrlRef` is listed in `INTEGRATION_SECRET_CONFIG_KEYS` so the pointer never rides out either. The migration is a separate gated step: `scripts/encrypt-discord-webhook-url` (dry-run by default, `--apply` to write, digests not URLs, idempotent, reads both plaintext stores and unsets both, refuses to write at all when the key ring is unusable). Run it after this deploys — the old image reads the plaintext field directly. Evidence: full backend tier 446 suites / 4167 tests green; tsc 0; eslint .ts 0 errors at the 1210-warning baseline. Eight-arm mutation ledger, no survivors: each arm reverts one piece and every arm reddens a named test. * test(discord): witness the migration's write-before-unset ordering, and drop the inert pre-flight vera's hold at `e6d4077c`: the ordering that prevents data loss in `encrypt-discord-webhook-url` was unwitnessed. Moving both `$unset`s above the `connectorSecrets.put` (the arrangement where a failing put destroys the only copy) left the suite 8/8, and deleting the `listWithUnavailableKey()` pre-flight reddened nothing. Both observations were right, and the second one was the more interesting: `listWithUnavailableKey` returns refs whose `keyId` is not in the ring. It cannot fail. A call that cannot fail is not a guard, so it is gone — the script no longer claims to fail fast, and its comment now states the invariant that actually protects the data: per row, the `put` completes before either plaintext is cleared, so ANY failure leaves that row whole and a re-run reports it as still waiting. Two witnesses replace the claim: - a failed `put` leaves both plaintexts in place and reports nothing migrated (asserts the platform store, the legacy `config.webhookUrl` store, the absence of a ref, and that a following dry run still sees `candidates=1, migrated=0`); - clearing happens only after the row's own ref is stored, one row further in: the second row's `put` rejects, so the first must have completed end to end (the test cannot pass vacuously) and the second must be untouched. The empty-ring test keeps its place as the ring-shaped instance, with its comment corrected: what protects it is the ordering, not the deleted pre-flight. Mutation ledger, script only: vera's mutation reddens 3 named tests (both new witnesses + the empty-ring one); removing the dry-run guard reddens 1 as a control that the ledger sees this file at all; re-adding the deleted pre-flight is a SURVIVOR by design, which is the evidence for removing it rather than witnessing it. * docs(discord): correct the migration comment's claim about the deleted pre-flight vera's correction at 74198, and she is right: `listWithUnavailableKey` CAN fail. Its first statement is `const { keys } = parseKeyRing()` (services/connectorSecrets.ts:171), and `parseKeyRing` throws `ConnectorSecretConfigurationError` on an unset, malformed or duplicated ring and `ConnectorSecretKeyMissing` when the active key is absent (:49-79). I wrote "it cannot fail" by quoting the find line and dropping the line above it. So the pre-flight was redundant, not inert: `put` computes `encrypt(material, parseKeyRing())` before `upsertEncrypted` touches anything (:138-146), so an unusable ring already fails the first row ahead of either `$unset`. Deleting it remains right; the reason recorded for the deletion was wrong, and on the one script in this lane whose damage is irreversible. The comment now says what is true, in vera's wording; the same false claim is corrected in the test comment that cited it. Comment-only, so the clearance at `d015ad25` stands: the only changed lines in this commit begin with `//`, `*` or `/**`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The catalogue half asks the same question the pod-scoped half asks
V2ConnectorsPage's catalogue branch renderedinstallation.errorMessage || <generic>, andInstallableInstallation.errorMessagehas two kinds of writer: the reconciler's human-authoredconstants (
projection missing,install lock expired) andmarkProjectionFailure, which stores theprojector's own exception text —
connect ECONNREFUSED <addr>:443. So a raw exception was printed in astranger's connector row: disclosure of internal addresses and stack-adjacent text, not diagnosis.
Measured on main at
f5ded480while fixing the pod-scoped half, and disclosed there rather than foldedin (TASK-131).
React escapes the child, so this is not XSS. It is what the row says about us.
The shape, carried over from #1878
#1878 gave
Integrationa provenance flag,errorMessageUserFacing, written together with the messageby one owner (
connectorDeliveryFailureService). The same pair, for the same reason:installableInstallationService.tsuserFacingInstallationError(reason)anddiagnosticInstallationError(error)— the two ways the field gets a value, so message and flag are written together;markProjectionFailuretakes the builderinstallableReconciler.ts$sets (stale lock, missing projection) take the builderInstallableInstallationerrorMessageUserFacingBoolean, default falseinstallableCatalogService.tsV2ConnectorsPage.tsxerrorMessageUserFacing && errorMessage, exactly as the pod-scoped branch already doesa flagged reason (the reconciler's channel-gone sentence) still renders verbatim;
connect ECONNREFUSED …does not.wren 73858's follow-up, in the same PR
externalFeedService's error$setomitted the flag. A$setthat omits a key inherits the row'sstored value, so omitting it was only safe on a row that had never carried one — a row already flipped
by a permanent delivery failure kept
true, and this writer's provider text would then render underthat earlier provenance. It now states
false, and its comment says why instead of claiming the flag"stays unset". The delivery-failure docstring gains the fallback line: stated, not defaulted.
Reachability, measured by wren at
fa04e6ae: the only writer oftrueruns on the Telegram and Slackflip paths, which need a bound
config.chatId, and noxorinstagramrow can have one — so this iscontract-tightening, not a live disclosure.
The copy pass, and why it rode this PR (wren 73914, vera 73913/73915)
The mechanism here is what makes two strings user-facing: the page prints
errorMessageverbatimwhenever a writer declared it readable, and the only such writers are the reconciler's own constants.
So they were rewritten in this PR rather than shot as-is:
'install lock expired'-> "Setup was interrupted before it finished. Try again."'projection missing'-> "This connector's channel is gone. Retry to rebuild it."The second deviates from wren's 73914 wording, under her own "adjust if a string reads wrong in
context": the generic fallback is "Setup didn't finish.", and a flagged reason that reads as a
variation on it would make two different states look like one — vera 73915's constraint. Both strings
name no internal step, and both are pinned by the writer's own witness, so either moving is a red test.
Evidence (UI hold)
Before/after at 1200 and 390 under
docs/design/evidence/, captured withscripts/ui-evidence-shot.mjs: the after side is this branch, the before side isa2913955(the base),same DB, same token, each width captured back to back so the row's relative age line does not differ.
Two rows in one frame carry both flag states: the flagged Slack row prints its reason in both
revisions, and the unflagged Telegram row goes from the raw
connect ECONNREFUSED 10.4.4.7:443to"Setup didn't finish." The
diffof the two innerText dumps is that one line and nothing else; the.txtpair ships beside the PNGs because the text is the quotable half.Witnesses
installableInstallationService.test.js— a real projection failure storeserrorMessageUserFacing: false;the stale-lock sweep stores
true; the missing-projection sweep storestrue(both read back from thedatabase after a real
sweep()).installableCatalogService.test.js— the mapper carriestrueandfalsethrough, with the messagestill present both times: the page decides what to show, not the mapper.
externalFeedService.test.js— the sync-failure$setnames the flag beside the status and message.V2ConnectorsPage.test.tsx— an unflagged catalogue failure prints the generic sentence and never theraw exception; a flagged one prints the message verbatim.
One pre-existing fixture changed, disclosed rather than quietly relaxed: the catalogue Retry test
asserted
projection missingverbatim from a fixture with no flag. That fixture now carries the flag itswriter sets — the assertion's intent (a human-authored reason renders) is unchanged, and the two new
witnesses cover both directions.
Ledger (each mutation aimed, each reddening its own witness by name)
TASK-131: a catalog failure prints the generic sentence, not the raw exception that wrote the rowsurvives repeated component failures and claims the same projection on retrysweeps stale installing and inactive activating rows to errormakes a missing active projection a retriable parent errorcarries the flag beside the message…(+ the existing catalog DTO test)marks integration as error when sync failsScope and verification
components[].errorMessageis deliberately untouched: the mapper carries it, but no reader renders it(the page reads component
statusonly), so a flag there would be a contract with no consumer.One trap worth recording:
installableInstallationService.tsends in a CommonJSmodule.exports = {…}tail, which replaces the ES exports — the builders had to be named there too, or every
require()of themodule would see them as undefined (found by the first test run, not by reading).
src/v2)eslint . --ext .ts: 0 errors / 1211 warnings (the base counts)TASK-131. Gate: @vera.