experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs(pubsub): rename tile:drift → gate:rejected, fold tile-drift.ts into tile-ipc-gate.ts

+47 -42
+47 -42
docs/pubsub-state-machine.md
··· 185 185 | System (in-process main) | any publish | always allow; never subscribes | 186 186 | Tile with valid token | publish to topic T | grant includes `publish` for T's class (subject to per-class rules below) | 187 187 | Tile with valid token | subscribe to topic T | grant includes `subscribe` for T's class | 188 - | Tile with revoked/missing token | any op | reject — logged as `tile:drift` telemetry, never silently dropped | 188 + | Tile with revoked/missing token | any op | reject — logged as `gate:rejected` telemetry, never silently dropped | 189 189 190 190 Per-class rules (the capability-grant shape implements these): 191 191 ··· 217 217 218 218 1. **Channel allowlisted?** `registerTileIpc(channel, handler, descriptor)` 219 219 is the only way to attach a `tile:*` listener. An unregistered 220 - channel receives a default handler that logs `tile:drift` and drops. 220 + channel receives a default handler that logs `gate:rejected` and drops. 221 221 2. **Sender identity verified?** `event.sender` (the `WebContents` that 222 222 sent the frame) must match the `WebContents` that owns the 223 223 `payload.token`. This closes the "forged token" hole — a tile with 224 224 XSS cannot smuggle out another tile's leaked token because the 225 225 sender frame wouldn't match. 226 226 3. **Payload schema valid?** Each channel descriptor declares its 227 - expected shape. Malformed frames log `tile:drift` and drop. 227 + expected shape. Malformed frames log `gate:rejected` and drop. 228 228 4. **Token valid & grant consistent with channel?** The channel 229 229 descriptor names the capabilities required; absent ones → reject. 230 230 5. **State-at-receive matches channel's expected transition window?** 231 231 E.g., `tile:lifecycle:ready` may only arrive while the tile is in 232 - `loading`; arrival in any other state → reject + `tile:drift`. 232 + `loading`; arrival in any other state → reject + `gate:rejected`. 233 233 6. **Sender role allowlisted for this channel?** E.g., 234 234 `tile:lifecycle:ready` must come from a tile's own preload, never 235 235 from Core or System. 236 236 237 237 Only after all six pass does the handler run. Rejections are never 238 - silent; every drop emits `tile:drift` with a structured reason so CI 238 + silent; every drop emits `gate:rejected` with a structured reason so CI 239 239 can fail on regressions. 240 240 241 241 **Performance budget**: schema validation (step 3) uses hand-rolled ··· 351 351 - **Tile publishes are gated by token existence.** A tile in `loading` 352 352 has no token yet (the token is minted on `loading → ready`). Any 353 353 publish attempt from a `loading` tile fails the chokepoint's token 354 - check and is rejected + logged as `tile:drift`. No capability check 354 + check and is rejected + logged as `gate:rejected`. No capability check 355 355 is needed — there's no token to consult. 356 356 - **No replay needed.** Because subscribers exist before any publisher 357 357 fires, `cmd:request-registers` and the `registeredPayloads` cache in ··· 381 381 ## Bypass detection 382 382 383 383 Everything routed through the FSM is enforced at the gate; rejections 384 - emit `tile:drift` telemetry (see §Authorization rules). The only way 384 + emit `gate:rejected` telemetry (see §Authorization rules). The only way 385 385 for a message to escape the FSM is for code to call a lower-level 386 386 Electron API directly, sidestepping the gate entirely. The FSM cannot 387 387 prevent this — anyone writing main-process code can do anything — ··· 395 395 `webContents.send` that throws on the same pattern. 396 396 - **Off-path window creation** — `new BrowserWindow()` with a 397 397 `peek://{tileId}/...` URL outside the tile launcher. Mitigation: lint 398 - rule + dev-mode assertion in the FSM that every BrowserWindow with a 399 - tile URL has a corresponding `registered → loading` transition. 400 - (Inherited from tile-lifecycle-fsm.md §Drift detectors.) 398 + rule + dev-mode assertion that every BrowserWindow with a tile URL has 399 + a corresponding `registered → loading` transition. (Inherited from 400 + tile-lifecycle-fsm.md.) 401 401 402 402 Both are dev/CI-only assertions. Production has nothing to check — 403 - correct code can't trip them, and incorrect code is caught at review 404 - or in dev. 403 + correct code can't trip them, and incorrect code is caught at review or 404 + in dev. 405 405 406 - What is explicitly **not** a drift detector: 407 - - Gate rejections (enforcement, already covered). 406 + What is explicitly **not** tracked as rejection telemetry: 408 407 - Startup races (if they occur, the FSM design is wrong — fix the design, 409 408 don't paper over it with a detector). 410 409 - Unrouted publishes (legitimate for domain events with no listeners). 411 410 412 - **Drift emission rate limit**: `tile:drift` publishes are themselves 411 + **Rejection emission rate limit**: `gate:rejected` publishes are 413 412 rate-limited to one event per `(tileId, reason)` tuple per second, with 414 413 a dropped-count aggregator. A buggy or malicious tile spamming rejected 415 - frames cannot amplify into a drift-publish storm that saturates the 414 + frames cannot amplify into a telemetry storm that saturates the 416 415 broadcaster. 417 416 418 417 ## Module layout ··· 432 431 wiring, pre-publish hooks. Imports `pubsub-fsm.ts` only. Exposes 433 432 `unsubscribeAllByPrefix(tileId)` for lifecycle cleanup. 434 433 - **`backend/electron/tile-ipc-gate.ts`** (new) — the single IPC 435 - chokepoint (see §The IPC chokepoint). Exposes `registerTileIpc(channel, 436 - handler, descriptor)`. Runs channel allowlist check, sender-frame 437 - cross-check against token owner, payload schema validation, token 438 - validation, state-at-receive assertion, sender-role allowlist. 439 - Every drop emits `tile:drift`. No `tile:*` IPC handler is attached 440 - except through this gate. 434 + chokepoint AND the rejection-telemetry surface AND the bypass 435 + detectors. One module owns the whole gate boundary: 436 + - `registerTileIpc(channel, handler, descriptor)` — the only way to 437 + attach a `tile:*` IPC handler. Runs the six-step validation 438 + sequence (see §The IPC chokepoint). 439 + - `emitGateRejected(reason, ctx)` — rate-limited publish of 440 + `gate:rejected` (one event per `(tileId, reason)` per second, 441 + dropped-count aggregator). Used by the gate itself. 442 + - `installDirectSendGuard()` + `installOffPathWindowGuard()` — 443 + dev-mode monkey-patches catching the two bypass categories 444 + (§Bypass detection). Called at app init. 445 + - Subsumes what Phase 2 scaffolded as `tile-ipc-sender-check.ts` — 446 + the sender-frame check becomes step 2 of the pipeline, not a 447 + separate helper module. 441 448 - **`backend/electron/tile-ipc.ts`** — individual channel handlers, 442 449 registered via `registerTileIpc`. On `tile:pubsub:publish` / 443 450 `tile:pubsub:subscribe`: the gate has already validated the frame, so 444 451 the handler just calls `pubsubFsm.allow(...)` for per-topic 445 - authorization → publish or reject+drift. This is where the 446 - lifecycle↔pubsub boundary sits. 452 + authorization → publish or reject. This is where the lifecycle↔pubsub 453 + boundary sits. 447 454 - **`backend/electron/tile-lifecycle.ts`** — state store + token 448 455 lifecycle. On `loading → ready`: mint token. On `→ unloading` / 449 456 `→ crashed`: revoke token, call `pubsub.unsubscribeAllByPrefix()`. 450 457 Publishes `tile:state-changed` for observers. Imports from 451 458 `pubsub.ts` (one-way); never imported by it. 452 - - **`backend/electron/tile-drift.ts`** (new) — bypass detectors (the 453 - two from §Bypass detection) + dev-mode wrap of `webContents.send`. 454 - Owns the `tile:drift` GLOBAL topic used by the gate's rejection 455 - telemetry. 456 459 - **`backend/electron/tile-lazy.ts`** — load-on-dispatch pre-publish 457 460 hook. Already lives on the lifecycle side (it triggers 458 461 `registered → loading`). Continues to register as a pre-publish hook ··· 483 486 prepares for it — this check is worth landing early because it's 484 487 security, not cleanup. 485 488 **Test**: new unit test that simulates a mismatched sender + valid 486 - token → rejected with `tile:drift`. 489 + token → rejected with `gate:rejected`. 487 490 3. **Phase 3 — Collapse to one command-result path.** Delete the 488 491 `tile:command:result` IPC (preload send site + main-process 489 492 handler). All command results flow through capability-gated pubsub ··· 513 516 load-on-dispatch. **Test**: dispatcher to a tile whose preload throws 514 517 → user-visible notification within 10s. 515 518 8. **Phase 8 — IPC chokepoint + bypass detectors.** Introduce 516 - `tile-ipc-gate.ts` with `registerTileIpc(channel, handler, 517 - descriptor)`. Migrate every existing `tile:*` `ipcMain.on` through 518 - the gate. Gate runs the six-step validation sequence (§The IPC 519 - chokepoint). Add lint rules + dev-mode wraps for the two bypass 520 - categories (direct `webContents.send('pubsub:...')`, off-path 521 - `new BrowserWindow` for tile URLs). Wire gate rejections to the 522 - `tile:drift` topic with structured reasons so CI can fail on any 523 - drift event. 519 + `tile-ipc-gate.ts` as the single module owning the gate boundary: 520 + `registerTileIpc(channel, handler, descriptor)` with the six-step 521 + validation pipeline (§The IPC chokepoint); rate-limited 522 + `gate:rejected` rejection telemetry; dev-mode bypass detectors 523 + (direct `webContents.send('pubsub:...')`, off-path `new BrowserWindow` 524 + for tile URLs). Subsumes Phase 2's `tile-ipc-sender-check.ts` (folded 525 + in as step 2 of the pipeline). Migrate every existing `tile:*` 526 + `ipcMain.on` through `registerTileIpc`. Add ESLint rules for the two 527 + bypass categories. Wire rejection events so CI fails on any 528 + `gate:rejected` publish during test. 524 529 **Test**: regression suite stays green; a deliberately-seeded 525 - bypass in a test fixture trips the detector; every `tile:*` 526 - channel has at least one passing + one rejecting gate test. 530 + bypass in a test fixture trips the detector; every `tile:*` channel 531 + has at least one passing + one rejecting gate test; rate limiter 532 + test (100 rejections with same reason → one publish). 527 533 528 534 Each phase is independently shippable and each has a narrow failure mode. 529 535 ··· 553 559 matrix. 554 560 - `backend/electron/tile-ipc.ts` — individual `tile:*` channel handlers. 555 561 - `backend/electron/tile-ipc-gate.ts` (new, Phase 8) — single IPC 556 - chokepoint. 557 - - `backend/electron/tile-drift.ts` (new, Phase 8) — bypass detectors + 558 - `tile:drift` topic owner. 562 + chokepoint + rejection telemetry + bypass detectors. Owns the 563 + `gate:rejected` topic. 559 564 - `backend/electron/tile-preload.cts` — renderer-side publish/subscribe 560 565 API + command registration. 561 566 - `backend/electron/main.ts:216` — broadcaster registration site