Observer verification: fix coverage bookkeeping, restart sessions when scope widens - #380
Observer verification: fix coverage bookkeeping, restart sessions when scope widens#380Maximo-Guk wants to merge 13 commits into
Conversation
db563f7 to
4aa84ec
Compare
Preview:
|
| gatekeeperRecord.resourceTitle = description.title; | ||
| gatekeeperRecord.resourceUrl = description.url; | ||
| gatekeeperRecord.hasSlashCommands = description.hasSlashCommands; | ||
| this.storage.gatekeepers.put(gatekeeperRecord); |
There was a problem hiding this comment.
[P1] Keep the widened capability hidden until old sessions are invalidated
The record becomes collaborator-visible here, but the restart below still has to resolve getSharingManager(), sync storage, and sleep for 100 ms. During that interval an existing build collaborator can use the sequential ID to pipeline getGatekeeperById(id).openSession() and read from this brand-new resource without ever passing observer verification. Unlike the revocation delay, this is a first-time grant of access, so the grace window violates the observer invariant. Please gate access by the session generation / verified scope until reconnect, or otherwise invalidate collaborator sessions before publishing the record.
There was a problem hiding this comment.
Note that, obviously, any writes have to happen before the abort or they will be lost. Moreover, the grace period is important to make sure writes actually settled. An abort() breaks the output gate, causing existing in-flight writes never to land. At the very least, we have to await ctx.storage.sync(), but that also gives a chance for a client to jump in with a request.
Perhaps the easiest solution is to use blockConcurrencyWhile().
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.sync();
// Throwing from blockConcurrencyWhile also resets the DO.
throw new Error("Gadget restarted because a new connection was added.");
});
Another option would be to store in-memory that the given gatekeeper should be blocked from being accessed -- the mark naturally goes away after restarting. But, this seems more complicated, and blockConcurrencyWhile() should do the trick I think.
| #restartIfShared(reason: string): void { | ||
| this.getSharingManager().then(sharing => { | ||
| if (sharing.listCollaborators().length === 0) return; | ||
| return this.scheduleAccessRestart(reason); |
There was a problem hiding this comment.
[P1] Do not let this delayed abort overtake a later access mutation
A widening call can sync and enter the 100 ms wait, then a concurrent owner request can remove a collaborator: the permission edge is written synchronously, but that request awaits observer teardown/listing refresh before reaching its own scheduleAccessRestart(). The earlier timer can therefore call ctx.abort() first; because abort ignores the output gate, the later revocation write may be lost and the collaborator can become authorized again after restart. Coalesce these restarts and perform a final sync while preventing new access mutations immediately before aborting.
| // trip happened, since nothing persisted in the Workshop contains it. An implementation | ||
| // that merely checked the record would keep accepting him here. | ||
| await setVerifyOutcome(accountLabel(bobAccount), { allow: false, reason: DENIED_REASON }); | ||
| const revoked = await submitExternalMessage({ callerEmail: bob, gadgetKey, prompt: "hi" }); |
There was a problem hiding this comment.
[P1] Wait for the restart triggered by this denial
This failed re-verification scrubs Bob's persisted choice and schedules ctx.abort() about 100 ms later, but the test returns immediately and disposes its workspace/session. observer-reverification.test.ts documents that an abort landing after the last client leaves crashes local workerd; because these tests are concurrent, this can fail an unrelated sibling. Keep a probe alive and wait until it observes the disconnect (or otherwise settle the restart) before leaving the test.
|
Submitted 3 actionable inline review findings. |
| // whoever can't will simply be denied their next open. This cannot loop: a second identical | ||
| // failure finds the entry already scrubbed, so no flag and no restart. A re-prompt that | ||
| // repairs the failure never reaches here, and step 6 re-persists full coverage. | ||
| if (scrubbedCoverage) { |
There was a problem hiding this comment.
[P1] Invalidate existing sessions before awaiting repair UI
By this point a failed addObserver() may already have scrubbed persisted coverage, but the restart is not scheduled until this catch runs. A configured open first awaits the collaborator-controlled configureCb.configure() retry (and later diagnostic/rollback RPCs), so a callback that never settles leaves other previously admitted sessions usable indefinitely after re-verification confirms the collaborator no longer has access. Revoke the old session generation, or at least schedule/coalesce the restart, immediately when the persisted choice is scrubbed and before any repair callback is awaited.
| // exists by then, so the client's retry is clean. | ||
| #restartIfShared(reason: string): void { | ||
| this.getSharingManager().then(sharing => { | ||
| if (sharing.listCollaborators().length === 0) return; |
There was a problem hiding this comment.
[P2] Restart only when the affected role is present
This checks for any collaborator even though the callers widen different scopes: addGatekeeper() widens only build, while binding/promotion widens only use. Adding an unbound connection to a workspace with only use collaborators, or binding one when all collaborators are build, therefore aborts every in-flight session although nobody has new verification requirements. Pass the affected role into this helper and schedule only when listCollaborators() contains that role.
|
Submitted 2 actionable inline review findings. |
This comment was marked as resolved.
This comment was marked as resolved.
kentonv
left a comment
There was a problem hiding this comment.
I started reviewing this but I see you're making other changes so decided to post what I have and come back later.
| gatekeeperRecord.resourceTitle = description.title; | ||
| gatekeeperRecord.resourceUrl = description.url; | ||
| gatekeeperRecord.hasSlashCommands = description.hasSlashCommands; | ||
| this.storage.gatekeepers.put(gatekeeperRecord); |
There was a problem hiding this comment.
Note that, obviously, any writes have to happen before the abort or they will be lost. Moreover, the grace period is important to make sure writes actually settled. An abort() breaks the output gate, causing existing in-flight writes never to land. At the very least, we have to await ctx.storage.sync(), but that also gives a chance for a client to jump in with a request.
Perhaps the easiest solution is to use blockConcurrencyWhile().
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.sync();
// Throwing from blockConcurrencyWhile also resets the DO.
throw new Error("Gadget restarted because a new connection was added.");
});
Another option would be to store in-memory that the given gatekeeper should be blocked from being accessed -- the mark naturally goes away after restarting. But, this seems more complicated, and blockConcurrencyWhile() should do the trick I think.
| // exists by then, so the client's retry is clean. | ||
| #restartIfShared(reason: string): void { | ||
| this.getSharingManager().then(sharing => { | ||
| if (sharing.listCollaborators().length === 0) return; |
There was a problem hiding this comment.
Instead of checking whether any collaborators exist on the sharing graph, we should check whether anyone is actually connected. We can keep a count of the number of live OverseerClientInterface and UseOverseerInterface objects (increment count in constructor, decrement in disposer) in order to decide whether anyone is connected. (We can even count connections by the owner separately from collaborators.)
|
|
||
| let fail = (reason: string, err?: unknown) => { | ||
| failures.set(gk.id, {accountId, reason}); | ||
| // The persisted record is what asserts this collaborator was verified for this |
There was a problem hiding this comment.
I think this is incorrect. The existence of an entry in accountChoices doesn't assert anything about the user's verification status. It simply records the user's choice, so that we don't have to ask them again.
A secondary, though less important, role of accountChoices is to record which gatekeepers may have already had the user registered via a successful addObserver(). Those gatekeepers potentially need later removeObserver() calls to clean up. However, this isn't strictly necessary -- if we accidentally leave a user registered on removeObserver() when they no longer have access, then when they show up in excludeObservers in the future, we can detect that the observer is no longer present and remove them at that time. So existence in accountChoices is more of a hint for this purpose.
But regardless, nothing should be assuming that an entry in accountChoices means the user is actually verified.
Under this understanding, I believe this commit can be removed, since there's not necessarily any need to delete a choice just because it failed re-verification. If we do want to delete it, though, we would also need to call removeObserver() on the gatekeeper to fully remove this observer. But if we do that, we also need to make sure that the observer doesn't have any other live connections open, because if they do, those connections would then be able to bypass excludeObservers.
So I think we should actually just remove this commit.
| let pruned = false; | ||
| for (let key of Object.keys(record.accountChoices)) { | ||
| if (!inScopeIds.has(Number(key))) { | ||
| delete record.accountChoices[Number(key)]; |
There was a problem hiding this comment.
Similar to the other comment -- this seems to misunderstand accountChoices.
I think we can leave the record alone. If the gatekeeper ever becomes in-scope again, then the user will probably want to keep the same choice they made earlier.
Note, though, that in the meantime, the user is still registered as an observer on the gatekeeper (and even with the change written here, they still would be -- there's no call to gatekeeper.removeObserver() here). This means they may show up in excludeObservers spuriously. We should have the excludeObservers handling detect that the gatekeeper is no longer in-scope for the user, and then remove the observer from the gatekeeper, even though the observer is still in the workspace's observers table. Currently (unless it changed elsewhere in this PR that I haven't seen yet), when the user shows up in excludeObservers it will actually cause the observation to be blocked, which isn't right if the gatekeeper is no longer in-scope for the user...
`ensureObserver` acquired the chosen account's verifier *outside* the per-gatekeeper `try`, so a rejection from the client's User DO -- the deterministic vendor-mismatch throw, or any cross-worker transport failure -- escaped its `Promise.all` instead of being collected like every other refusal. Two consequences: the user saw the transport error rather than the message naming the connection and account to fix, and there was no repair prompt for a failure that a re-authenticate would have cleared. It also raced the rollback. A mid-flight `Promise.all` rejection abandons the siblings still in flight, so the `newlyAdded` snapshot the terminal catch rolls back could be missing a registration one of them was about to add. Move the `getVerifier` call inside the `try`, where `fail()` already handles a settled denial and an operational failure identically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n fails. `ensureObserver`'s rollback removed the gatekeeper registrations of every binding that failed the call, not just the ones the call created. For a collaborator who was already an admitted observer, that de-registered them from a gatekeeper they had previously been verified against -- and a de-registered observer is one the gatekeeper stops naming in `ObservationDescription.excludeObservers`, so an observation it would have excluded them from is admitted with nothing left to block it. The reachable sequence is a collaborator whose upstream access is revoked, whose re-open therefore fails, and whose pre-existing live session then watches the owner's agent read a page they cannot access. So roll back the failed bindings only on a first-ever verification, where the minted observerId is discarded along with the unpersisted record and a registration left behind would linger unresolvable. A returning observer's id is already persisted, so keeping their registrations is fail-closed (a registration can only add exclusion names) and the next successful open's `addObserver` overwrites the verifier. This restores the invariant `registeredBeforeCall` was introduced to state: roll back only what this call added. `invalidated` is the set that distinguishes them -- a binding that failed and has not verified since, so a repaired pass takes its entry back out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ens.
Authorization and observer verification run only at open(). Nothing re-ran
them when the set of gatekeepers a collaborator must be verified against
*grew* mid-session -- adding a connection, or binding one into a gadget --
so a collaborator who opened before the growth kept a live session holding
access they were never verified for.
Fix it with the mechanism already used to revoke a collaborator: generalize
scheduleRevocationRestart() to scheduleAccessRestart(reason) and add
client reconnects and re-opens against the new scope. It is a no-op when the
workspace has no collaborators, so a solo workspace is never disturbed.
Four sites widen scope and now restart: addGatekeeper, a permanent
bindWorkpiece, a merge that promotes a binding edge into "use" scope, and a
denied re-verification that scrubbed a persisted account choice. The merge
case compares the effective account-requiring "use" scope before and after
promotion rather than restarting on any promotion, since most merges promote
neither a gadget with bindings nor an edge to a connection anyone is verified
against. It reads the scope through the non-throwing gatekeeperVendorId()
rather than #inScopeGatekeepers("use"), whose observerVendorId() throws on a
legacy record with no creationSpec -- an unrelated legacy connection must not
turn an accepted merge into an error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`addGatekeeper` published the gatekeeper record before awaiting the gatekeeper's `describe()`, because `getGatekeeperFacet(id)` resolved the class from that record. The DO's input gate is open across the await and ids are allocated sequentially, so a live `build` session could guess the id and `getGatekeeperById()`/`openSession()` on the owner's brand-new connection -- which gates on nothing but record existence -- for as long as `describe()` took, all of it before `#restartIfShared` severed it. `getGatekeeperFacet` now optionally takes the class directly, so the record is published exactly once, after `describe()` resolves. Nothing a gatekeeper's `describe()` can reach calls back into the overseer to resolve itself by record, so no caller needs the early put. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
receiveExternalMessage() checked only the caller's role. Observer verification -- which is how a collaborator earns the right to see what the workspace has read -- runs at open(), so a "build" collaborator who never opened the workspace, or whose upstream access was since revoked, could still drive the agent and have it answer out of chat history and gadget storage. Extract the gate open() applies into authorizeCollaborator(): resolve the effective role from the permission graph, then run ensureObserver for that role. Both entry points call it. The external path passes requireRole: "build", so an insufficient role is denied before verification runs -- a "use" collaborator would otherwise be verified, or told to go fix a verification failure, for access this path can never grant them. It also passes no configureCb, since there is no channel to prompt on: an unverified caller is told to open the workspace in a browser, which is where configuration happens. roleRank is exported for the requireRole comparison, so it ranks rather than string-compares. Also has open() await ambient reconciliation before authorizing rather than between the role check and verification, which is where the two halves now join. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/observers.md gains a "Restarting when verification scope widens" subsection: the four triggers, why the merge trigger compares scopes rather than firing on any promotion, why shrinking scope and role rises are deliberately not triggers, why addGatekeeper's publication order is load-bearing under the restart, and where the enforcement moment actually falls for each trigger. Step 3 gains the record prune, the scrub-and-restart failure path and the returning-observer rollback rule; edge cases 3 and 5 are rewritten around them, and Step 6's justification for an orphaned entry is corrected -- a registration is what admits an open, so a stale one grants nothing on its own. docs/sharing.md renames scheduleRevocationRestart and documents the abort's second purpose, whose trigger is a grant rather than a revocation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scheduleAccessRestart() syncs, waits ~100ms, then calls ctx.abort(), which ignores the output gate. The sync covered the write that triggered *this* restart, but the timer runs concurrently with everything else: a removeCollaborator() that has already written its sharing edge and is still awaiting tearDownLostObservers()/refreshAffectedCollaboratorListings() gets aborted with the revocation still buffered. The DO comes back with the collaborator still a collaborator, while the owner's UI says the removal succeeded. That is not a delayed revocation, it's a lost one, so unlike the abort delay itself it isn't inside any tolerance. So sync again immediately before the abort, with nothing awaited in between, and coalesce concurrent triggers onto one timer instead of racing several. This makes the storage write safe, which is the unbounded part. The awaited external effects can still be cut short: a skipped removeObserver() leaves an orphaned registration (harmless -- the gate is admission, and every open re-runs addObserver), and the listing refresh self-heals on reconnect. Also record, beside the existing residual note in docs/observers.md, that the tolerance for an access change taking effect is 5s -- which is what makes the abort delay an accepted residual rather than a defect, and this lost write not.
bindWorkpiece()'s guard asked whether the edge was permanent and its target account-requiring, but never whether that target was already in "use" scope. So binding a second name onto an already-bound connection severed every session on a shared workspace for a widening that didn't happen. Replace the condition with the before/after comparison mergeChatChanges() already uses. #accountRequiringUseScope() filters on `pending` and visibleBindings (subsuming the chatId check) and on gatekeeperVendorId (subsuming the creationSpec/vendorId check), so this is a smaller condition built from an existing helper, and both widening triggers now read identically.
two roles widen independently: a new connection enters every "build" collaborator's scope at once and no "use" collaborator's until a gadget binds it, and binding one enters "use" scope having been in "build" scope since it was created. So adding an unbound connection severed every session on a use-only workspace, and binding one severed every session on a build-only workspace, in both cases without changing anyone's verification requirements. Pass the affected role and skip when no collaborator holds it. The scrub trigger passes none: a failed re-verification isn't a widening and must sever regardless of role. Also record two claims where the next reader will look for them, both raised in review: - Why the pre-abort sync is sufficient rather than merely a narrower window. The input gate is closed for the duration of a storage operation, so nothing can interleave a mutation between that sync and the abort; the window it closes is the open one before it, across scheduler.wait(100). - Why the scrub trigger schedules its restart in the catch rather than at the scrub, leaving a delay the failing collaborator controls. Stalling the re-prompt preserves exactly the sessions that never re-opening would, so it is a way to decline to leave rather than a way in -- and scheduling at the scrub would cut off every repair before a human could answer it. Tests cover both directions of the role filter; covering one would leave half the condition unexercised.
A sync() followed by ctx.abort() only narrows the window it was meant to close: a request delivered after the sync resolves can still write a mutation that the abort then discards. Run the flush and the reset together inside blockConcurrencyWhile() instead, throwing out of the callback to reset the object -- nothing is delivered to the object for the duration of the block, so there is no moment between "everything is durable" and "the object is gone" at which anything can run. The two sync() calls collapse into the one inside the block, which covers every write issued before it (including a concurrent access mutation's). The scheduler.wait() stays outside, since blocking concurrency across the whole delay would stall unrelated requests. The rejection is swallowed: unlike ctx.abort(), this form rejects, and all three direct callers fire and forget. This retracts the input-gate argument the previous commit rested on: the gate is not what makes the barrier airtight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A restart's whole effect is to sever live sessions, so the sharing table is the wrong thing to consult: a collaborator who isn't connected has no session to cut, and an entry for one bought a workspace-wide reset that reached only the owner. OverseerImpl now counts live sessions by the capability each holds, through a joinSession() pairing modelled on joinPresence(). Both client interfaces join synchronously in their constructor and leave in [Symbol.dispose], and #restartIfShared -- renamed #restartIfSessionsAffected, since it no longer asks about sharing -- gates on a live non-owner session of the affected role. Being synchronous is the point: the old form reached the sharing manager over RPC, so a widening could be missed outright when that lookup failed, and the restart was scheduled some unbounded time after the change. The counter is deliberately not derived from #presence, which looks like it already holds this: a session joins presence only once its fetchProfile() resolves, so a just-opened session is briefly invisible there -- fine for a roster, fail-open for an access decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`excludeObservers` blocked whenever a named observer was still authorized in the sharing graph, without asking whether that observer could actually see the connection naming them. A "use" collaborator's opens only verify them against gadget-bound connections, so unbinding one leaves their registration on it untouched -- their next open neither re-registers nor removes it -- and the gatekeeper goes on naming them forever, blocking observations from a connection they have no way to reach. `#enforceExcludeObservers` now takes the gatekeeper id its only caller already has, and blocks only when the observer is still authorized *and* that gatekeeper is still in their role's verification scope. Otherwise the observation proceeds and they are de-registered from that one gatekeeper; the record stays, since they are still a collaborator and a rebind must put them back in scope rather than start them from scratch. The scope test is fail-closed and narrow: out of scope means only "role is `use`, the connection requires an account, and no gadget binds it". It reuses #accountRequiringUseScope() rather than #inScopeGatekeepers, whose observerVendorId() throws on a legacy record with no creationSpec -- an unrelated legacy connection must not turn the observation path into an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9e5ab69 to
a33a8e8
Compare
This comment was marked as resolved.
This comment was marked as resolved.
| // workspace the first open after an ambient capsule appears bounces itself once; the capsule | ||
| // exists by then, so the client's retry is clean. | ||
| #restartIfSessionsAffected(reason: string, affectedRole?: CollaboratorRole): void { | ||
| if (!this.#hasCollaboratorSession(affectedRole)) return; |
There was a problem hiding this comment.
[P1] Count admissions before observer verification yields
ensureObserver() snapshots inScope before awaiting the modal/verifier, but this check only sees sessions whose interface constructors have already run. If this is a collaborator’s only open, the owner can add a gatekeeper while configure() is blocked; the count is still zero, so no reset is scheduled, and the old open then returns a build capability that can access the new gatekeeper without any addObserver() check. receiveExternalMessage() never joins this count and has the same interleaving before starting its chat. Hold an admission lease/generation across authorization, or recheck scope immediately before exposing the capability/starting the chat.
| #leaveOutputsFanout: () => void; | ||
|
|
||
| [Symbol.dispose]() { | ||
| this.#leaveSession(); |
There was a problem hiding this comment.
[P1] Keep descendant capabilities represented after parent disposal
Capabilities returned by Cap’n Web are independently owned. A use collaborator can retain the UseGadgetClientInterface returned by getGadget(), dispose only this parent, and make the live-session count reach zero; notifyClosed() merely marks that disposal as expected and does not revoke the retained child. The owner can then bind a new restricted resource without scheduling a reset, after which the retained child can call connectToGadget() against the gadget’s current bindings. The build parent at line 9357 has the same issue. Keep the authorization lease alive for descendant capabilities or make every descendant enforce the admitted generation.
| await this.#removeObserverFromGatekeepers(observerId, gatekeeperIds); | ||
| } | ||
| for (let observerId of outOfScope) { | ||
| await this.#removeObserverFromGatekeepers(observerId, [gatekeeperId]); |
There was a problem hiding this comment.
[P1] Revalidate scope before removing each observer
outOfScope is classified once, but these removals await one by one. With two excluded observers, the first removeObserver() opens the input gate; while it is pending, the owner can bind this gatekeeper and the second observer can open and successfully re-register. This stale second removal then deletes that fresh registration and the method admits the excluded observation even though the observer can now reach it; later exclusions may omit them too. Recheck the current role/scope before each removal (or serialize cleanup with scope changes).
|
Submitted 3 actionable inline review findings. |
kentonv
left a comment
There was a problem hiding this comment.
Bugfix: Don't let a delayed abort swallow a concurrent access change.
This commit is suspicious. It seems to be complaining that a randomly-placed abort() could leave the DO in an inconsistent state, if something else is happening in the meantime that gets interrupted in the middle.
But if that's the case, then that something else is the buggy bit. Every piece of code should be tolerant of a random crash interrupting it. Usually, DO's atomicity guarantees take care of it: any writes performed without an intervening await are automatically coalesced into a transaction. But anything that does writes around an async operation needs to consider what happens if the async operation never returns (due to a crash).
Of course, adding and removing observers from a gatekeeper is inherently async, and we can't even wrap it in a transaction since the gatekeeper itself is modifying its own storage. (I do want to create cross-facet transactions at some point... but we don't have them yet.)
The rule to follow here is: Ensure the gatekeeper overestimates. That is:
- addObserver() must succeed before the observer is granted any access.
- remoteObserver() must not be called until the observer's access has been revoked.
Since it's an over-estimate, in the event of a badly-timed crash, a gatekeeper can end up having an observer registered who doesn't actually have access. That is OK: it only causes that observer to possibly show up in excludeObservers in the future. At that point, the overseer checks and discovers that the excluded observer doesn't have access in the first place, and can then remove them from the gatekeeper, thus bringing it into sync.
In any case, though, I don't quite understand the situation that the first paragraph of the commit description is worried about. If removeCollaborator() has already removed the sharing edge (and the observer has also been removed from the sharing table), then it doesn't really matter if we fail to finish removing the observer from all gatekeepers -- the gatekeepers can be left over-estimating.
Five fixes for the races the PR review surfaced, all in the same spirit: the session count must cover everything a collaborator holds a role's access through, and observer bookkeeping must only ever err toward over-registration (a spurious registration blocks fail-closed; a lost one fails open). - addGatekeeper publication window: the record must be durable before the scheduled reset, but the severed sessions stay live for the reset's ~100ms response-delivery delay and ids are guessable. When a restart was scheduled, the new id is marked in the in-memory #gatekeepersPendingRestart set -- in the same synchronous block as the put -- and getGatekeeperById/openSession refuse it with a retryable error until the reset destroys the mark with the sessions. - In-flight authorization: authorizeCollaborator holds a joinSession lease for the resolved role across ensureObserver, so an open parked on the configuration prompt or verifier RPCs counts as a session and a widening restarts it. receiveExternalMessage holds a "build" lease for a non-owner caller across the whole call, since it produces a reply from workspace data without ever constructing a counted interface. - Retained descendant capabilities: GadgetClientImpl, UseGadgetClientInterface, and GatekeeperClientImpl now count toward #hasCollaboratorSession for their own lifetime when minted into a collaborator session, since a client can dispose the parent interface while retaining a child stub. Owner mints and internal constructions don't count. - excludeObservers teardown: each observer is re-classified against current state adjacent to their own awaited removal. An observer put back in scope by a bind + fresh open mid-teardown blocks the observation instead of having their fresh registration deleted by the stale removal. - Returning-observer rollback: a failed open no longer rolls back any of a returning observer's registrations. The persisted observerId is shared with concurrent opens, so the rollback could delete a registration a concurrent successful open just made and persisted -- under-registering, the fail-open direction. Only a first-ever verification (whose observerId is private to the call) still rolls back. Also fixes the TS18047 null guard in observer-role-scope.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Why:
Before this PR: We didn't sever existing observers, this was just actually documented in the original plan "existing observers see an incremental modal for just the new binding on their next open..."
The main gap we have right now is adding a binding does not restart live sessions, so an already-open collaborator is only verified against it at their next open.
What:
authorizeCollaborator() is now responsible for being the single gate for checking whether observers have access to underyling data. Everything routes through here, including
receiveExternalMessage()which previously only checked the role and never checked whether the observer had access to the underlying data.We also decided to abort the DO in order to revoke the RPC capabilities, we already use the same mechanism when revoking collaborators and we can just the same mechanism here. We made this a little less disruptive by checking graph first to see if there's any other collaborators before aborting the DO
Testing:
I added extensive integration tests in
observer-role-scope.test.ts,observer-reverification.test.tsandexternal-message-verification( even though it's actually used yet in any gatekeepers ) for testing the observer verification logic