Skip to content

refactor(authz): the socket write path runs the shared membership rule, not a copy (TASK-165) - #1943

Merged
lilyshen0722 merged 1 commit into
mainfrom
kai/task165-one-membership-definition
Sep 27, 2026
Merged

lilyshen0722 merged 1 commit into
mainfrom
kai/task165-one-membership-definition

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Cut from 6a52978b (main after #1941). Backend only, no version bump.

What this closes

server.ts:503 defined its own isPodMember for the socket write path and exported it at :814, so the strict membership rule had two definitions: utils/isPodMember.ts's isListedPodMember (what createMessage and, since #1940/#1942, the connector and PG readers run) and this copy. Two definitions of a load-bearing rule is the shape the last three PRs kept collapsing, and this was the last one left.

The copy had also already drifted, in the opposite direction from the one TASK-161 was about:

member document local copy createMessage / shared predicate
'user-1' (ObjectId) admitted admitted
{ toString: () => 'user-1' } (the test double) admitted admitted
{ _id: ObjectId('user-1') } (Mongo populated the doc) refused — member.toString() is [object Object] admitted

So the copy's contract and the HTTP write path beside it disagreed about a populated member document. Whether that could bite in production is now measured, and it could not: authorizeSocketPodAccess does a bare Pod.findById(podId) with no .populate(), and models/Pod.ts carries no autopopulate plugin and no populate hook (grep clean, with a positive control in the same file), so members arrives as ObjectId[] and member.toString() yields the hex — the copy never refused a real member. The [object Object] defect was real in the predicate's contract and unreachable at this call site, which is what makes the populated-document arm a contract guard against a copy returning rather than evidence of a live divergence. (Measured by @vera, 74692.)

The change

  • server.ts imports { isListedPodMember } from ./utils/isPodMember and calls it on the write branch of authorizeSocketPodAccess; the local definition is deleted.
  • isPodMember is removed from this module's exports rather than re-exported as an alias: one rule, one name (the only importer was the helper arm in server.test.js, which now reads the rule where it lives). isListedPodMember is the strict form — pod.members alone — so the socket path is exactly as strict as it was for ObjectId members, and no stricter or looser than createMessage.

Witnesses at the call site, not the definition

server.test.js, through authorizeSocketPodAccess(socket, podId, 'post'):

arm what it pins
refuses a departed creator on the socket write path createdBy present, members: [] → null + Not authorized to post for this pod. Reddens if this path is pointed at the permissive predicate.
admits a populated member document, so the socket path runs the shared predicate members: [{ _id: { toString } }] → the pod is returned. This is the arm the deleted copy failed — it reddens if a local copy returns.
treats string and ObjectId-like members as valid pod members, and no creator now reads utils/isPodMember directly: member shapes admitted, creator-not-listed refused.

The second arm is the reason this row was worth a PR rather than a comment, and @vera's 74672 point is why both arms exist: a non-member refusal cannot distinguish the shared predicate from a local copy, and neither can a departed-creator refusal — both copies are strict today. What distinguishes them is drift, so the populated-document arm is the drift guard and the departed-creator arm is the wiring guard. Neither is redundant with the other, and the populated-document arm should not be deleted as "the same as the member arm" — it is the only arm that fails when this module grows a copy again.

Mutation ledger

Baseline/restore 11/11, --forceExit (the suite imports server and never exits on its own).

mutation red
M1 the local copy returns at the write path 1 — admits a populated member document…, alone
M2 the write path imports the permissive predicate 1 — refuses a departed creator…, alone
M3 control: the gate admits everyone 1 — the departed-creator arm
M4 control: the gate denies everyone 2 — both member arms

No survivors. M3 does not redden the member arms by construction (they are members, so true admits them), which is why M4 exists as the other half of the control.

Verification

  • Three suites require server directly; only server.test.js imported the removed export. The other two destructure { app } alone — middleware/rateLimitIpKeySeparation.test.js:64 and routes/mcpGrants.noRedirect.test.js:96 — and were run green on this head rather than reasoned about (2 suites / 8 tests). Same conclusion, with a number that survives checking (measured by @vera, 74692).
  • server.ts: 0 errors, 4 warnings — byte-identical to the warning set at HEAD (lines 234/264/265/587, all pre-existing max-len), none on a line this PR touched.
  • server.test.js: 0 eslint problems. Un-gated .js corpus as before.
  • Not touched, deliberately: TASK-164 (the scheduled feed writer reads no membership at all — a read plus a pause rule, not a swap) and the permissive callers on activity.ts/podInvites.ts/activityService.ts/decisionRequestService.ts, which are the product question on TASK-166, not this row.

…e, not a copy (TASK-165)

`server.ts` defined its own `isPodMember` for the socket post path and exported
it, so the strict rule had two definitions in `backend/`. The copy had also
drifted from the rule it exists to mirror: it compared `member.toString()`, which
on a member document Mongo has populated renders `[object Object]` — so a
populated member was admitted by `createMessage` and refused by the socket path
beside it.

The socket write path now calls `isListedPodMember` from `utils/isPodMember` —
the export TASK-161 placed beside the permissive predicate, and the one #1942's
two readers import — and this module no longer exports a membership predicate of
its own, so the rule has one home and no second name.

Witnesses sit at the call site, not the definition: a departed creator
(`createdBy` present, `members` empty, which is the shape `leavePod` leaves) is
refused on the socket write path, and a populated member document is admitted —
the second arm is the one the removed copy failed, so it reddens if a local copy
returns, and the first reddens if the write path is pointed at the permissive
predicate instead. The helper arm that read the removed export now reads the rule
where it lives. No behaviour changes for string or ObjectId members.
@lilyshen0722
lilyshen0722 added this pull request to the merge queue Sep 27, 2026
samxu01 pushed a commit that referenced this pull request Sep 27, 2026
#1943 was open when this entry was written; the past tense asserted a merge
that hasn't happened. Entry-only, one sentence.
Merged via the queue into main with commit f77c18e Sep 27, 2026
19 checks passed
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