feat(connectors): push a user-scoped invalidation so a visible tab re-reads (TASK-135) - #1751
lilyshen0722 wants to merge 5 commits into
Conversation
lilyshen0722
left a comment
There was a problem hiding this comment.
Gate at 1acf5f68479fba69e8e490a5598eb0c54359fe2d: passes, nothing blocking. One unpinned line worth an assertion.
The room is safe to trust. joinUserRoom reads socket.userId, which server.ts:485 sets from jwt.verify(token, JWT_SECRET) in the io.use handshake — so the id is server-derived from a verified token and a client cannot name another user's room. user_ has no other use in the backend, so there is no room-name collision. The payload is { userId, reason } and carries no grant or connector content, so even a mis-joined room would leak nothing but the fact that something moved.
Backend suites (connectorEventService + grants.mint + grants.read + integrations.linkedUserId) 81/81; frontend V2ConnectorTools + V2ConnectorsPage 59/59. Each mutation changes one line (git diff --numstat 1+1-):
joinUserRoomwithout its!socket.userIdguard (every unauthenticated socket joinsuser_undefined): 1 reduserRoomcollapsed to one shared room — the cross-user case: 9 redemitConnectorsChangedFornarrowed toreq.userIdalone: 3 redemitConnectorsChangedwithout its!userIdguard: 2 rednotifyConnectorsChangeddropping the author emit: 1 red- page listener / page reconnect / tools listener / tools reconnect removed: 1 red each
The one gap. Rewiring the attenuate path to emitConnectorsChanged(req.userId, …) instead of connectionOwnerId(connection) leaves 81/81 green. That line is the one place where the caller is an agent and the owner is someone else, and your own comment says why it must be the owner — an agent has no Connectors page. Unpinned, a later edit "simplifying" it to the caller would silently restore the stale-row bug on the mint path. One assertion that the attenuate emit targets the connection owner and not the caller would close it.
A method note, since it nearly cost me the result: my first mutation pass had a wrong backup path, so the restore silently failed and mutations 2–5 stacked on each other (the diff= counts climbed and the restore run came back red). I redid the whole set with git checkout -- as the restore. The numbers above are from the clean run.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
56763a2 to
157a628
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
Re-gate at 157a628eac (rebased onto ac544d5c): passes; the gap is pinned.
- src patch-id over
backend/routes,backend/services,backend/server.ts,frontend/src/v2/componentsis8d6fd3f2, unchanged from the1acf5f68gate. The source really is test-only on top. (Several non-test files differ between the two heads —cli/*,agent-config.ts,V2ConnectorTools.tsx— but all of them sit below the new merge-base: that is main moving under the rebase, not this PR, which is why the PR-relative patch-id is the right measure and is identical.) - Backend suites 82/82 (was 81), restored 82/82.
emitConnectorsChanged(connectionOwnerId(connection), …)→req.userId: 1 red. Green at the previous head, so the new assertion is what closed it. That was the whole finding.
Nothing further from me on #1751.
lilyshen0722
left a comment
There was a problem hiding this comment.
Re-gate at 157a628eac (rebased onto ac544d5c): passes; the gap is pinned.
- src patch-id over
backend/routes,backend/services,backend/server.ts,frontend/src/v2/componentsis8d6fd3f2, unchanged from the1acf5f68gate. The source really is test-only on top. (Several non-test files differ between the two heads —cli/*,agent-config.ts,V2ConnectorTools.tsx— but all of them sit below the new merge-base: that is main moving under the rebase, not this PR, which is why the PR-relative patch-id is the right measure and is identical.) - Backend suites 82/82 (was 81), restored 82/82.
emitConnectorsChanged(connectionOwnerId(connection), …)→req.userId: 1 red. Green at the previous head, so the new assertion is what closed it. That was the whole finding.
Nothing further from me on #1751.
|
New head f512367. CI failed on the previous head and the failure was mine, in an instructive way.
Removed the two rows. Guard 16/16, connector + grants suites 98/98 combined. Worth flagging as a process note rather than a footnote: the guard runs in the full backend suite, and I had verified this change with the focused integrations and grants suites, which do not include it — so the local signal was green while CI's required check went red on both heads. The guard's own header says it exists exactly for this ("this test finds the next one before the push, in npm test"), and it was written in September after CodeQL found three un-limited routes after a push. It did its job; I just hadn't run it. Running The remaining un-limited write routes in this file ( |
lilyshen0722
left a comment
There was a problem hiding this comment.
Re-gate at f512367af74973e4733593a03c10df0476291bd4: passes, nothing blocking.
The delta since my 157a628e gate is exactly two files — integrations.ts (the limiter on the two routes) and routeRateLimitGuard.baseline.json (the two rows retired). Everything gated before is untouched.
The CodeQL fix is real, not a suppression, and the guard proves it both ways. Suites: guard + connectorEvent + integrations.linkedUserId + grants.mint + grants.read = 98/98; guard alone 16/16.
- uncap
POST /:id/connect: 1 red — uncapPOST /:id/disconnect: 1 red - re-add a retired baseline row for a now-capped route: 2 red. The baseline cannot be padded to silence a route; a second test asserts it only lists registrations that still violate. That is why retiring those two rows was mandatory here rather than optional.
- empty the baseline entirely: 1 red, 230 known violations surface — the positive control that the guard reads the baseline at all.
Placement is right, and I checked the thing that would have made it wrong. Both routes mount the limiter ahead of auth, which is what the guard's own rule requires. That ordering is only safe because integrationsRateLimitKey is IP-keyed (req.ip ? ipKeyGenerator(req.ip) : 'anon'); had the bucket been keyed on req.user.id, running before auth would have collapsed every unauthenticated request onto one key and turned the cap into a denial-of-service lever. It is IP-keyed, so pre-auth placement caps unauthenticated floods as intended.
One consequence to know, not a defect: these two routes now share the 30/min write bucket with the five integration writes beside them, per IP. That is the module's stated design, and an OAuth connect/disconnect flow is nowhere near it.
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ f512367 — v2 Connectors + Tools gated from the diff, no fixture: the frontend delta is hook wiring only (connectors_updated + connect → load()), no copy, no class, no CSS touched (git diff ac544d5c..f512367a --name-only has no .css / i18n file). Re-read does not flash: load() never sets loading back to true, so the visible list is replaced in place. Ran V2ConnectorTools.test.tsx + V2ConnectorsPage.test.tsx at this head: 62/62. Design call (sprint-impl 69620): user room is the right scope — the page is the viewer's own inventory, and a pod-room subscription via joinPod would mark the viewer present in every listed pod as a side effect of watching a list. Src patch-id over routes/services/server/components: 763c6688. Note: main is now ba4c20c (#1752), so this head is BEHIND; a rebase-only head with the same patch-id gets a quiet re-pin.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
f512367 to
b219e1d
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ b219e1d — re-pin after the rebase onto ba4c20c: src patch-id 763c6688 over backend/routes + backend/services + server.ts + frontend/src/v2/components is identical to my f512367 gate, same 13 files, +538/−11. Carried from the diff; nothing re-measured. Note: main has since moved again (bb889c3), so this head is BEHIND once more — same patch-id on the next rebase gets the same quiet re-pin.
lilyshen0722
left a comment
There was a problem hiding this comment.
Carry re-gate at b219e1db: carries. Patch-id 1ef6914b identical to the f512367a gate, measured against current main (bb889c37); same 13 files, same 4 commits. Rebase only, so every finding from that gate stands — including the rate-limit guard discriminating in both directions (uncap either route 1 red; re-add a retired baseline row 2 red).
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
b219e1d to
43455b3
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
Carry re-gate at 43455b38: carries. Patch-id 1ef6914b identical to the f512367a gate, measured against current main (c381031e, which #1748 just moved); same 13 files, same 4 commits. Rebase only, so every finding stands — the user-room authorization (socket.userId from the verified handshake), the attenuate-emit assertion, and the rate-limit guard discriminating both ways.
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ 43455b3 — re-pin after the rebase onto c381031 (#1748 merged): src patch-id 763c6688 over backend/routes + backend/services + server.ts + frontend/src/v2/components is identical to my f512367 and b219e1d gates, same 13 files, +538/−11, 4 commits, merge-base = current main. Carried from the diff; nothing re-measured.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
43455b3 to
cbf4d32
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ cbf4d32 — re-pin after Lily's rebase onto 373ac26; patch-id 763c6688 over backend/routes + backend/services + backend/server.ts + frontend/src/v2/components identical to f512367 / b219e1d / 43455b3, same 13 files (+538/−11) and 4 commits. Carried from the f512367 gate, nothing re-measured.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
cbf4d32 to
0933a35
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ 0933a35 — re-pin after Lily's rebase onto 66a7827; patch-id 763c6688 over backend/routes + backend/services + backend/server.ts + frontend/src/v2/components identical to f512367 / b219e1d / 43455b3 / cbf4d32, same 13 files (+538/−11) and 4 commits. Carried from the f512367 gate, nothing re-measured.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
0933a35 to
f1fabb8
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ f1fabb8 — re-pin after Lily's rebase onto 260a2fd (#1743 merge); patch-id 763c6688 over backend/routes + backend/services + backend/server.ts + frontend/src/v2/components identical to f512367 / b219e1d / 43455b3 / cbf4d32 / 0933a35, same 13 files (+538/−11) and 4 commits. Carried from the f512367 gate, nothing re-measured.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
f1fabb8 to
112e9c0
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ 112e9c0 — re-pin after sprint-impl's rebase onto e77b1b4 (#1767 merge); patch-id 763c6688 over backend/routes + backend/services + backend/server.ts + frontend/src/v2/components identical to f512367 / b219e1d / 43455b3 / cbf4d32 / 0933a35 / f1fabb8, same 13 files (+538/−11) and 4 commits. Carried from the f512367 gate, nothing re-measured.
e053f8a to
6a8ced3
Compare
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
6a8ced3 to
ce89846
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ ce89846 — re-pin after sprint-impl's rebase onto e938afc (cli + review-checklist docs merges); patch-id 393ef85e over backend/routes + backend/services + backend/server.ts + frontend/src/v2/components identical to 13a4d09 / e053f8a, range-diff shows all 4 commits '=', same 13 files (+538/−11), Lily-authored. Carried from the 13a4d09 stamp, nothing re-measured.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
ce89846 to
a37149a
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ a37149a — re-pin after the rebase onto 79306fd; patch-id 393ef85e over backend/routes + backend/services + backend/server.ts + frontend/src/v2/components identical to 13a4d09 / e053f8a / ce89846, range-diff shows all 4 commits '=', interdiff differs only in one index line and one hunk offset in V2ConnectorTools.tsx (context moved by what landed on main), no +/− lines. Same 13 files (+538/−11), Lily-authored. Carried from the 13a4d09 stamp, nothing re-measured.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
a37149a to
5b95f8a
Compare
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
5b95f8a to
7ea5af0
Compare
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
7ea5af0 to
7337dd6
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ 7337dd6 — rebase onto 8bfca7b; src patch-id 393ef85e unchanged over backend/routes + backend/services + backend/server.ts + frontend/src/v2/components, all 4 commits '=' in range-diff vs 7ea5af0. Gate from f512367 / 13a4d09 carries. Test-dir edits (granted 1h ago → granted 1h) are outside this scope and will not void the stamp.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
7337dd6 to
07fab24
Compare
|
Post-merge fix — head Why this PR has 5 commits now. #1782 changed the rendered grant age from Evidence, all measured on the merged tree at this head:
Test-only edit under |
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ 07fab24 — rebase onto ba3306f (#1782 in base). src patch-id moved 393ef85e → 16254cfc from context lines only: range-diff vs 7337dd6 shows commits 2–4 '=' and commit 1 differing solely in the ActGlyph/MarkGlyph import context in V2ConnectorTools.tsx + V2ConnectorsPage.tsx, zero +/− content lines over backend/routes + backend/services + backend/server.ts + frontend/src/v2/components. New 5th commit (test-only, granted 1h ago → granted 1h) is outside this scope. Gate from f512367 / 13a4d09 carries.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
07fab24 to
78228fb
Compare
…-reads
TASK-131 closed the staleness a viewer could see and left one case open, named
on TASK-135: a tab that is already VISIBLE still renders whatever it read at
mount when another client of the same user mints or revokes a grant, or
changes an integration. Hiding and showing the tab was the only remedy, and the
new 60s clock made it worse by keeping the ages moving — the page looks live
while the row is stale.
This adds the push. connectorEventService emits 'connectors_updated' into
'user_{userId}', the room a socket joins once at connection from the token it
already presented. Writes emit from their own sites: grant create / attenuate /
revoke in routes/grants.ts, and the connector lifecycle writes in
routes/integrations.ts (create, github-app, patch, delete, connect,
disconnect, connect-code). Both Connectors components subscribe and re-read,
and re-read again on socket reconnect — an event fired while the socket was
down is not replayed.
Why the user room and not the pod_{podId} fan-out the board uses: the page is
the viewer's own inventory, so a pod-room event would reach members who cannot
read those rows. Subscribing the page to each listed pod's room instead goes
through joinPod, which also emits presence, and would mark the viewer present
in every pod the page lists.
The join is a function in the service rather than two lines in server.ts so the
room name has one spelling and the guard is unit-testable; server.ts is not
importable in a test, and a missing join is silent — every emit still succeeds
and reaches an empty room.
…ner, not the caller The one path where the caller and the invalidated user differ: attenuation is agent-initiated, so the socket that must re-read belongs to the connection owner — an agent has no Connectors page. Rewiring the emit to the caller left every test green (sprint-review on #1751), which is the same silent-stale-row bug this PR exists to remove, restored by an edit that looks like a simplification. Ran alone: the mutation is 1 red / 10 pass, restored 11/11.
…d connector bucket CodeQL flagged two new high-severity js/missing-rate-limiting alerts in the files this PR changes, at POST /:id/connect and POST /:id/disconnect. The gap is pre-existing — neither route ever carried a limiter — but the invalidation emit put both in the diff, and the analyzer is right on the merits: the five write routes beside them share one bucket (integrationRateLimit.ts says so in its own comment), so these two were the pair that could be burst while every other connector write was capped. Adding writeIntegrationsRateLimit is the fix the module was built for, not a suppression. The remaining un-limited write routes (/:id/send, /:id/ingest-tokens, DELETE /:id, DELETE /:id/ingest-tokens/:tokenId) are untouched by this PR and left for a follow-up rather than widened into it. Integrations suites 75/75 across 7 files; eslint 0; tsc clean. (cherry picked from commit 91c51c6)
…imit baseline The rate-limit guard burns down in both directions: a route that becomes compliant fails the second test until its baseline row is removed, so a fix cannot leave the file over-reporting. Capping POST /:id/connect and POST /:id/disconnect in the previous commit did exactly that, and CI caught it at 0c388f2 — the guard runs in the full backend suite, which the focused suites I ran locally do not cover. That guard is itself the September response to CodeQL finding three un-limited routes after a push; this is the first row of its integrations.ts block to be retired rather than reported. Guard 16/16; connector + grants suites 98/98 combined.
#1782) #1782 landed on main as ba3306f and rule 3 renders the grant age as `pod · granted 1h` — the `ago` suffix is gone (main's own assertions at this file were updated in the same PR). These two are the TASK-135 tests added by this branch, so they were the only ones left asserting the old string; they fail on the merged tree without this. Mutation-checked: reverting both to 'granted 1h ago' reds exactly these two.
78228fb to
2e18b25
Compare
Closes the limit TASK-131 named rather than hid, and filed as TASK-135.
The gap this closes
#1728(TASK-131) closed both staleness sources a viewer could see — no clock in rendering, one read at mount for the source. It explicitly left one case open: an already-visible tab receives nothing. Client A mints or revokes a grant, or changes an integration; client B sits on the Connectors page with the tab visible and keeps rendering what it read at mount. The only remedy was hiding and showing the tab, and the new 60-second clock made the failure harder to notice — the ages keep moving, so the page looks live while the row is stale.The change
A user-scoped invalidation,
connectors_updated, emitted intouser_{userId}:backend/services/connectorEventService.ts(new) —bindSocketIO/emitConnectorsChanged/emitConnectorsChangedFor/joinUserRoom, mirroring the shapetaskEventServicealready established for the board.backend/server.ts— joins each socket to its own user room at connection, from the JWT the socket already presented; binds the service alongside the other two.backend/routes/grants.ts— create, attenuate, revoke.backend/routes/integrations.ts— create, github-app, patch, delete, connect, disconnect, connect-code.connect.Two decisions worth the reader's time
Why
user_{userId}and not thepod_{podId}fan-out the board uses. The two reads behind this page are user-scoped (/api/integrations/user/allis{ createdBy: viewer }) and the page is the viewer's own inventory, so a pod-room event would reach members who are not entitled to the rows. The other way to get pod scoping — subscribing the page to every listed pod's room — goes throughjoinPod, which also emits presence: the Connectors page would mark the viewer present in every pod it lists. That is a visible social side effect on a different feature, so it is not something to fold in here.Why the join lives in the service.
server.tsis not importable in a test, and a missing join fails silently: every emit still succeeds, into an empty room. So the guard is a function (joinUserRoom) with the room name in one place, unit-tested directly.Attenuation is agent-initiated, so the socket it invalidates is the connection owner's, not the caller's — an agent has no Connectors page. That costs one lookup on an agent-only path and it cannot fail the mint that already happened.
Deliberately not emitted
POST /ingest(no user session; writes the message buffer, not the list)/:id/send(message delivery; emitting here would refetch every open tab per outbound message for no change)/:id/ingest-tokensPOST/DELETE (token management, not a row the Connectors page renders)Each of these is a "the page did not change" judgement, not a mechanism — worth a look at review.
What it does NOT cover, stated rather than implied
A grant minted by a different granter in a shared pod still does not push to this viewer. That is the pod-scoped case, and it needs the presence-free pod subscription described above. It is out of scope here and named on TASK-135.
Verification
Backend: 79/79 across
connectorEventService.test.js(new, 14),grants.mint.test.js(9),grants.read.test.js(9),integrations.linkedUserId.test.js(48), plus 40/40 across the eight existing integration/server suites as a regression check. Frontend: 59/59 acrossV2ConnectorsPage.test.tsxandV2ConnectorTools.test.tsx.The four new frontend tests fail without the subscription, and each is mutation-proven alone:
connectors_updatedsubscription removed (page)connectors_updatedsubscription removed (tools)user_undefinedeslint: 0 errors on every touched
.ts/.tsxfile; the.jstest files report the pre-existingimport/no-unresolvedcorpus, at or below their HEAD counts. tsc: no errors in any touched file.