Skip to content

feat(connectors): the catalogue half renders a message only when a writer declared it readable (TASK-131) - #1881

Merged
lilyshen0722 merged 4 commits into
mainfrom
kai/task131-catalog-half
Sep 25, 2026
Merged

lilyshen0722 merged 4 commits into
mainfrom
kai/task131-catalog-half

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

The catalogue half asks the same question the pod-scoped half asks

V2ConnectorsPage's catalogue branch rendered installation.errorMessage || <generic>, and
InstallableInstallation.errorMessage has two kinds of writer: the reconciler's human-authored
constants (projection missing, install lock expired) 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 of internal addresses and stack-adjacent text, not diagnosis.
Measured on main at f5ded480 while fixing the pod-scoped half, and disclosed there rather than folded
in (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 Integration a provenance flag, errorMessageUserFacing, written together with the message
by one owner (connectorDeliveryFailureService). The same pair, for the same reason:

where what changed
installableInstallationService.ts userFacingInstallationError(reason) and diagnosticInstallationError(error) — the two ways the field gets a value, so message and flag are written together; markProjectionFailure takes the builder
installableReconciler.ts both $sets (stale lock, missing projection) take the builder
InstallableInstallation errorMessageUserFacing Boolean, default false
installableCatalogService.ts hands the flag over beside the message, so the page never infers provenance from the shape of a string
V2ConnectorsPage.tsx the catalogue branch gates on errorMessageUserFacing && errorMessage, exactly as the pod-scoped branch already does

a 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 $set omitted the flag. A $set that omits a key inherits the row's
stored 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 under
that 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 of true runs on the Telegram and Slack
flip paths, which need a bound config.chatId, and no x or instagram row can have one — so this is
contract-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 errorMessage verbatim
whenever 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 with
scripts/ui-evidence-shot.mjs: the after side is this branch, the before side is a2913955 (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:443 to
"Setup didn't finish." The diff of the two innerText dumps is that one line and nothing else; the
.txt pair ships beside the PNGs because the text is the quotable half.

Witnesses

  • installableInstallationService.test.js — a real projection failure stores errorMessageUserFacing: false;
    the stale-lock sweep stores true; the missing-projection sweep stores true (both read back from the
    database after a real sweep()).
  • installableCatalogService.test.js — the mapper carries true and false through, with the message
    still present both times: the page decides what to show, not the mapper.
  • externalFeedService.test.js — the sync-failure $set names the flag beside the status and message.
  • V2ConnectorsPage.test.tsx — an unflagged catalogue failure prints the generic sentence and never the
    raw exception; a flagged one prints the message verbatim.

One pre-existing fixture changed, disclosed rather than quietly relaxed: the catalogue Retry test
asserted projection missing verbatim from a fixture with no flag. That fixture now carries the flag its
writer 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)

mutation witness that reddened
the catalogue branch renders the field again TASK-131: a catalog failure prints the generic sentence, not the raw exception that wrote the row
the raw exception is flagged readable survives repeated component failures and claims the same projection on retry
the stale-lock constant loses its flag sweeps stale installing and inactive activating rows to error
the projection-missing constant loses its flag makes a missing active projection a retriable parent error
the mapper drops the provenance it was handed carries the flag beside the message… (+ the existing catalog DTO test)
the feed writer omits the flag again marks integration as error when sync fails

Scope and verification

components[].errorMessage is deliberately untouched: 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.

One trap worth recording: installableInstallationService.ts ends in a CommonJS module.exports = {…}
tail, which replaces the ES exports — the builders had to be named there too, or every require() of the
module would see them as undefined (found by the first test run, not by reading).

  • backend: 60 tests / 5 suites (installation, catalog, feed, installables route, event handlers)
  • frontend: 810 tests / 78 suites (src/v2)
  • eslint . --ext .ts: 0 errors / 1211 warnings (the base counts)

TASK-131. Gate: @vera.

…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
lilyshen0722 added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 40178fa Sep 25, 2026
20 of 24 checks passed
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
`/**`.
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.

1 participant