From 1a014059297ae2eb2ffb6954ab2307f2f00b3510 Mon Sep 17 00:00:00 2001 From: Jason Ye Date: Wed, 9 Sep 2026 10:25:03 +0900 Subject: [PATCH 1/3] chore: add .imgbotconfig, scoped to the images that actually ship Imgbot is free on the student pack. The only images in this repo where compression changes what a user downloads are the 361 pet sprites under CLIPulseCore/Sources/CLIPulseCore/Resources/Pet (4.7 MB). They are declared via .process("Resources") in Package.swift, so they go into the app bundle -- and because that is a plain folder rather than an .xcassets catalog, actool's compression never touches them. Measured on a 30-file sample with zopflipng: 330.4 KB -> 283.8 KB, 14.1%, pixel-identical when both are decoded to RGBA. Extrapolated, ~0.66 MB off the bundle. Modest, but free and unattended. Excluded on purpose: *.xcassets - the App Store icon has hard requirements around alpha and dimensions; a few hundred KB is not worth debugging a rejected build, and actool recompresses these anyway. screenshots - already uploaded to App Store Connect; keep them byte-identical to what was submitted. schedule=monthly and minKBReduced=50 keep this from becoming PR noise once the one-time win is banked. Co-Authored-By: Claude Opus 5 --- .imgbotconfig | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .imgbotconfig diff --git a/.imgbotconfig b/.imgbotconfig new file mode 100644 index 00000000..8c051b18 --- /dev/null +++ b/.imgbotconfig @@ -0,0 +1,13 @@ +{ + "schedule": "monthly", + "minKBReduced": 50, + "prTitle": "chore(assets): lossless image compression", + "ignoredFiles": [ + "**/*.xcassets/**", + "*/*.xcassets/*", + "**/Assets.xcassets/**", + "**/screenshots/**", + "*/screenshots/*", + "**/fastlane/**" + ] +} From 99d8317a5f1b757e1e1aa33ed4c2b6188b9bacc3 Mon Sep 17 00:00:00 2001 From: YE Date: Wed, 9 Sep 2026 10:27:07 +0900 Subject: [PATCH 2/3] I built a producer for a plane that was retired three weeks ago MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked to "build the consumer" for `pterm:`, I went to build it and found this, in the source, measured: RemoteSessionPlane.isEnabled = false /// `false` — the app offers no remote sessions, TERMINALS or approvals. remote_sessions 4 all the owner's own, last 2026-07-16 remote_session_commands 0 pending rows are never swept — a true never remote_permission_requests 0 app_push_jobs 0 the durable zero The remote session plane — starting CLI sessions from the phone, STREAMING THE TERMINAL, remote approvals — was withdrawn across #499-#514, ~12,000 lines, after measuring that no non-owner ever used it. The `pterm:` consumer was removed with it. It has no consumer BY DESIGN, not by oversight. So "build the consumer" is a request to un-retire that plane, and I am not doing that as a side effect of a producer task. The replacement is alive and shipping: the phone reaches the Mac over LAN/tailnet directly (`LANTerminalHost` streams via `LANSessionControlClient.subscribeEvents`). I proposed that consumer myself, last session, without checking. Same mistake as the one in my own notes — count the population before building the mechanism — made while quoting it. ── SO THE CORRECT ACTION IS THE OPPOSITE ───────────────────────────── #549's producer consults only `remote_private_terminal_broadcast_enabled`. `main.swift:496` already gates the cloud task on `RemoteSessionPlane`; the producer did not. Flipping that ops flag would have redacted, batched and POSTed a session's output to a topic nothing subscribes to, for a plane both copies of that file declare retired. Not a leak — the relay authorizes and the READ policy still scopes subscribers — but real work and real egress in service of nothing. `RemoteSessionPlane.shouldRunPrivateTerminalProducer(configEnabled:isPaired:)` now ANDs the retirement. It lives there, not inline in main.swift, for the reason that file already gives above its sibling: main.swift is an executable target with no test bundle, and a predicate a test cannot reach is a predicate nobody has checked — which is exactly how this one shipped unchecked. ── AND THE PYTHON PRODUCER, WHICH WAS DEFAULT-ON ───────────────────── `helper/realtime_broadcast.py` is a complete `pterm:` producer whose gate has DEFAULTED ON since helper 1.24.0. Two independent reasons it delivers nothing, both measured: no consumer, and its write path (v0.65's direct mint+POST as `r0_broadcast`) is refused by Realtime because that role holds no INSERT on realtime.messages — invisibly, since the endpoint answers 202 either way. It has not delivered a byte since at least 2026-08-30 while still minting a token and issuing an HTTPS request per coalesced chunk. New `helper/remote_session_plane.py` mirrors the flag as the third copy, and `test_remote_session_plane.py` reads BOTH Swift sources and fails if any disagree — the repo already runs that drift gate between the two Swift copies; the Python one ships as a separate .pkg and is the copy most likely to be forgotten. ── WATCHED FAIL ────────────────────────────────────────────────────── un-retire the Swift plane -> "the ops flag must not outvote the retirement" FAILS set the Python copy True -> 3 tests fail, incl. both drift checks drop the gate call from the producer construction -> the wiring test FAILS That last one matters: a predicate nothing calls gates nothing, and this file family has shipped that mistake twice. 795 HelperSwift tests, 938 helper pytest (1 skipped). Co-Authored-By: Claude Opus 5 --- .../HelperKit/RemoteSessionPlane.swift | 30 +++++++++ .../Sources/cli_pulse_helper/main.swift | 12 +++- .../RemoteSessionPlaneTests.swift | 34 +++++++++++ helper/cli_pulse_helper.py | 11 +++- helper/remote_session_plane.py | 57 +++++++++++++++++ helper/test_remote_session_plane.py | 61 +++++++++++++++++++ 6 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 helper/remote_session_plane.py create mode 100644 helper/test_remote_session_plane.py diff --git a/HelperSwift/Sources/HelperKit/RemoteSessionPlane.swift b/HelperSwift/Sources/HelperKit/RemoteSessionPlane.swift index 55ed5e74..78754bb2 100644 --- a/HelperSwift/Sources/HelperKit/RemoteSessionPlane.swift +++ b/HelperSwift/Sources/HelperKit/RemoteSessionPlane.swift @@ -65,6 +65,36 @@ public enum RemoteSessionPlane { isEnabled && isPaired } + /// Whether the helper may run the PRIVATE `pterm:` terminal producer. + /// + /// Added 2026-09-09, after shipping that producer without this check. + /// + /// `pterm:` exists to stream a terminal to the phone. This enum's own + /// docstring says the app "offers no remote sessions, terminals or + /// approvals", and the app-side consumer was removed with the rest of the + /// plane — so with the config flag on and this predicate absent, the helper + /// would have redacted, batched and POSTed a session's output to a topic + /// nothing subscribes to, for a plane both copies of this file declare + /// retired. Not a leak (the relay authorizes, and the READ policy still + /// scopes subscribers to their own sessions), but real work and real + /// egress in service of nothing. + /// + /// The conjunction is the point, and it is the same shape as + /// `shouldStartCloudTask`: the ops flag can only ever turn the producer + /// OFF sooner, never on past the retirement. Un-retiring is one edit, in + /// one place, reviewed on its own — which is what the flag is for. + /// + /// Lives here rather than inline in `main.swift` for the reason stated + /// above `shouldStartCloudTask`: main.swift is an executable target with no + /// test bundle, and a predicate a test cannot reach is a predicate nobody + /// has checked. That is exactly how this one shipped unchecked. + public static func shouldRunPrivateTerminalProducer( + configEnabled: Bool, + isPaired: Bool + ) -> Bool { + isEnabled && configEnabled && isPaired + } + /// Why the cloud task is not running, for an operator reading the log. /// "Retired" and "unpaired" are different facts and must not print the /// same line — confusing them is how a real outage gets read as a config diff --git a/HelperSwift/Sources/cli_pulse_helper/main.swift b/HelperSwift/Sources/cli_pulse_helper/main.swift index 24893bd8..0ab54f92 100644 --- a/HelperSwift/Sources/cli_pulse_helper/main.swift +++ b/HelperSwift/Sources/cli_pulse_helper/main.swift @@ -366,7 +366,17 @@ case "daemon": let pairingWithoutContainer: () -> AppGroupConfigReader.AppPairing? = { nil } let bootCloudCfg = configStore.cloudConfigSnapshot(appGroupReader: pairingWithoutContainer) - let privateBroadcastOn = configStore.privateTerminalBroadcastEnabled + // ⛔ ANDed with the RETIREMENT, not just the ops flag. `pterm:` streams a + // terminal to the phone, and `RemoteSessionPlane` — mirrored in both + // packages with a drift test — says the app offers no remote terminals and + // the consumer was removed with the rest of that plane. Producing for it + // would be redaction, batching and egress in service of nothing. + // + // This check was MISSING when the producer shipped (#549). The flag alone + // would have turned it on. + let privateBroadcastOn = RemoteSessionPlane.shouldRunPrivateTerminalProducer( + configEnabled: configStore.privateTerminalBroadcastEnabled, + isPaired: bootCloudCfg.isPaired) let broadcastPublisher: TerminalBroadcastPublisher? if bootCloudCfg.isPaired && configStore.remoteRealtimeEnabled { let provider: @Sendable () -> HelperConfigStore.CloudConfig = { diff --git a/HelperSwift/Tests/HelperKitTests/RemoteSessionPlaneTests.swift b/HelperSwift/Tests/HelperKitTests/RemoteSessionPlaneTests.swift index 8b5ee2e2..d8a6f810 100644 --- a/HelperSwift/Tests/HelperKitTests/RemoteSessionPlaneTests.swift +++ b/HelperSwift/Tests/HelperKitTests/RemoteSessionPlaneTests.swift @@ -37,6 +37,40 @@ final class RemoteSessionPlaneTests: XCTestCase { /// "Retired" and "unpaired" are different facts. Printing the same line for /// both is how a deliberate withdrawal gets read as a pairing problem — /// the confidently-wrong-status class this project keeps paying for. + func test_thePrivateTerminalProducerIsGatedOnTheRETIREMENT_notJustItsOwnFlag() { + // #549 shipped the `pterm:` producer gated only on + // `remote_private_terminal_broadcast_enabled`. Flipping that flag would + // have produced terminal output for a plane this very file declares + // retired, whose consumer was removed with it. + // + // All four combinations, because the conjunction is the whole point. + for paired in [true, false] { + for cfg in [true, false] { + XCTAssertEqual( + RemoteSessionPlane.shouldRunPrivateTerminalProducer( + configEnabled: cfg, isPaired: paired), + RemoteSessionPlane.isEnabled && cfg && paired, + "cfg=\(cfg) paired=\(paired)") + } + } + // And concretely, while the plane is retired: nothing turns it on. + XCTAssertFalse(RemoteSessionPlane.shouldRunPrivateTerminalProducer( + configEnabled: true, isPaired: true), + "the ops flag must not be able to outvote the retirement") + } + + func test_theProducerGateMatchesTheCloudTaskGateShape() { + // Same shape as its sibling, so un-retiring flips both together rather + // than leaving a producer running against a plane with no consumer. + for paired in [true, false] { + XCTAssertEqual( + RemoteSessionPlane.shouldRunPrivateTerminalProducer( + configEnabled: true, isPaired: paired), + RemoteSessionPlane.shouldStartCloudTask(isPaired: paired), + "with the ops flag on, the producer gate must track the cloud-task gate") + } + } + func test_theStartupNoticeDistinguishesRetiredFromUnpaired() { let paired = RemoteSessionPlane.startupNotice(isPaired: true) let unpaired = RemoteSessionPlane.startupNotice(isPaired: false) diff --git a/helper/cli_pulse_helper.py b/helper/cli_pulse_helper.py index 1130c8fd..0086c3cb 100644 --- a/helper/cli_pulse_helper.py +++ b/helper/cli_pulse_helper.py @@ -18,6 +18,7 @@ from system_collector import CollectedAlert, collect_alerts, collect_device_snapshot, collect_sessions, estimate_provider_quotas from git_collector import GitCollector, project_paths_from_sessions import user_secret as _user_secret_module +from remote_session_plane import should_run_terminal_broadcast logger = logging.getLogger("cli_pulse.helper") @@ -627,8 +628,16 @@ def daemon(args: argparse.Namespace) -> None: # broadcasts, zero edge-fn calls). Even on, only PRIVATE sessions # broadcast: the local gate in `_post_stdout_chunk` skips public/unknown # sessions entirely, and the mint edge fn denies public. + # ⛔ ANDed with the RETIREMENT, not just the ops flag. `pterm:` streams a + # terminal to a plane that was withdrawn in #499-#514; nothing + # subscribes to that topic on any platform, and this producer's own + # write path (v0.65's direct mint+POST) has been refused by Realtime + # since 2026-08-30 because `r0_broadcast` holds no INSERT — invisibly, + # since the endpoint answers 202 either way. See remote_session_plane.py. broadcast_publisher = None - if getattr(config_for_manager, "remote_realtime_broadcast_enabled", False): + if should_run_terminal_broadcast( + getattr(config_for_manager, "remote_realtime_broadcast_enabled", False) + ): try: from realtime_broadcast import ( # type: ignore RealtimeBroadcastSink, diff --git a/helper/remote_session_plane.py b/helper/remote_session_plane.py new file mode 100644 index 00000000..29c5c51f --- /dev/null +++ b/helper/remote_session_plane.py @@ -0,0 +1,57 @@ +"""Whether the PYTHON helper offers the remote **session** plane. + +MIRRORS `HelperSwift/Sources/HelperKit/RemoteSessionPlane.swift` and +`CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/RemoteSessionPlane.swift`. +Three packages that cannot import each other now carry this constant, and +`test_remote_session_plane.py` reads the Swift source and fails if they +disagree — a copied constant without a drift gate is how a "retired" feature +comes back on one side only. + +WHY RETIRED +----------- +Measured against production 2026-08-30 (see the Swift file for the full table): +no non-owner ever started a remote session, `remote_session_commands` and +`remote_permission_requests` were a durable zero, and the app-side surfaces +were removed across PRs #499-#514. + +WHY THIS FILE EXISTS AT ALL +--------------------------- +The Python helper still constructs a `pterm:` terminal-broadcast producer when +`remote_realtime_broadcast_enabled` is set, and that flag has DEFAULTED ON since +helper 1.24.0. `pterm:` streams a terminal to the phone; the phone stopped +having anywhere to put it when the plane was retired. + +Two independent reasons that producer cannot deliver anything today, both +measured 2026-09-08: + + * NO CONSUMER. Nothing in the product subscribes to `pterm:` or `term:` — + no Realtime WebSocket client exists on macOS, iOS or Android. + * NO WRITE PATH. It takes v0.65's direct route (mint a token, POST to + /realtime/v1/api/broadcast as `r0_broadcast`). `r0_broadcast` holds no + INSERT on `realtime.messages`, and the owner of a hosted Supabase project + cannot grant it, so Realtime refuses the write. The endpoint returns + HTTP 202 either way, so the failure has always been invisible. + +It has therefore not delivered a byte since at least 2026-08-30, while still +minting a token and issuing an HTTPS request per coalesced chunk. + +NOT DELETED, GATED +------------------ +Same reasoning as the Swift file: behaviour change and source deletion are two +reversible steps, not one. Flipping this back to True restores the previous +behaviour exactly. +""" + +#: ``False`` — the helper offers no remote sessions, terminals or approvals. +IS_ENABLED = False + + +def should_run_terminal_broadcast(config_enabled: bool) -> bool: + """Whether to construct the ``pterm:`` terminal-broadcast producer. + + The conjunction is the point: the ops flag + (``remote_realtime_broadcast_enabled``) can only ever turn the producer OFF + sooner, never on past the retirement. Un-retiring is one edit, in one place, + reviewed on its own. + """ + return IS_ENABLED and bool(config_enabled) diff --git a/helper/test_remote_session_plane.py b/helper/test_remote_session_plane.py new file mode 100644 index 00000000..587bafc0 --- /dev/null +++ b/helper/test_remote_session_plane.py @@ -0,0 +1,61 @@ +"""The retirement flag agrees across all three packages, and gates the producer. + +The Swift copies already have a drift gate between themselves +(`RemoteSessionPlaneRetirementTests.test_theHelperCopyOfTheFlagAgrees`). This +adds the third: a copied constant without a drift gate is how a "retired" +feature comes back on one side only, and the Python helper is the copy most +likely to be forgotten because it ships as a separate .pkg. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from remote_session_plane import IS_ENABLED, should_run_terminal_broadcast + +REPO = Path(__file__).resolve().parents[1] +SWIFT_COPIES = [ + REPO / "HelperSwift/Sources/HelperKit/RemoteSessionPlane.swift", + REPO / "CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/RemoteSessionPlane.swift", +] + + +@pytest.mark.parametrize("path", SWIFT_COPIES, ids=lambda p: p.parent.name) +def test_the_python_copy_agrees_with_each_swift_copy(path: Path) -> None: + assert path.exists(), f"the Swift copy moved: {path}" + line = next( + (ln for ln in path.read_text(encoding="utf-8").splitlines() + if "static let isEnabled" in ln), + None, + ) + assert line is not None, f"the isEnabled declaration moved in {path.name}" + m = re.search(r"static let isEnabled\s*=\s*(true|false)", line) + assert m is not None, f"could not parse: {line!r}" + swift_value = m.group(1) == "true" + assert swift_value == IS_ENABLED, ( + f"{path.name} says isEnabled={swift_value} but the Python copy says " + f"{IS_ENABLED} — the retirement came back on one side only" + ) + + +def test_the_ops_flag_cannot_outvote_the_retirement() -> None: + # The conjunction, all four ways. + for cfg in (True, False): + assert should_run_terminal_broadcast(cfg) is (IS_ENABLED and cfg) + # Concretely, while retired: nothing turns the producer on. + assert should_run_terminal_broadcast(True) is False, ( + "remote_realtime_broadcast_enabled defaults ON since helper 1.24.0, so " + "without this the producer runs for a retired plane" + ) + + +def test_the_producer_construction_actually_calls_the_gate() -> None: + # A predicate nothing calls is a predicate that gates nothing — this file + # family has shipped that mistake twice. + src = (REPO / "helper/cli_pulse_helper.py").read_text(encoding="utf-8") + assert "from remote_session_plane import should_run_terminal_broadcast" in src + assert "if should_run_terminal_broadcast(" in src, ( + "the producer construction no longer routes through the retirement gate" + ) From 48ee982fb3b4de44880d5a838b57fd50f68a4abe Mon Sep 17 00:00:00 2001 From: YE Date: Wed, 9 Sep 2026 10:34:01 +0900 Subject: [PATCH 3/3] revoke is now enforced on the server too, and v0.82's support ticket should never be filed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ── v0.83 APPLIED: the server half of revoke ────────────────────────── `remote_helper_authorize_broadcast` is the ENTIRE write-side boundary for the private relay — `broadcast-terminal` runs as service_role, which is rolbypassrls, so no RLS policy is consulted. It authorized on four predicates: id, device, user, realtime_private. No status. No consent. Revocation posts `status='stopped'`. The function never read it. So after a user un-shared a session, the server kept authorizing writes to that session's topic, and revocation was enforced entirely by a client that could be stale, buggy, or replaced. One predicate: `and rs.status in ('pending','running')`. An allowlist of live states rather than `<> 'stopped'`, so a future terminal state does not keep authorizing; 'pending' included so a helper broadcasting before its first status post does not earn a 42501, which the Swift sink treats as an authoritative denial and suppresses for 60 s. Body is the LIVE definition read back with pg_get_functiondef plus the one predicate — production function bodies drift, so it was not reconstructed from the repo. Dry-run first: all assertions passed inside a transaction, then rolled back, and production was re-checked clean (predicate absent) before the real apply. APPLIED as ledger 20260909013221, then verified BY HAND — and not only by grepping the source, which proves nothing about behaviour: row_status stopped matched BEFORE v0.83 1 <- would have authorized matched AFTER v0.83 0 <- raises 42501 Blast radius measured first: remote_sessions = 3, all realtime_private, all 'stopped', 0 running. It authorizes strictly less and breaks nothing live. The three rows it newly refuses are the exact class it exists to refuse. STILL OPEN, and the edge function now says so precisely: consent. `cloudShared` is an in-memory helper flag never mirrored to the database, so a never-shared but running session would still authorize. Revocation is now enforced on both sides; consent is still client-only. The note that used to say "do not describe revocation as server-enforced anywhere" was corrected rather than deleted — it is half right now, and which half matters. ── v0.82: SUPERSEDED, DO NOT ASK ───────────────────────────────────── That file's whole purpose was to obtain a grant via a Supabase support ticket. Three things killed the ask: 1. The write path was BUILT WITHOUT IT — the relay uses service_role, which already holds INSERT. Proven end to end before it shipped. 2. The feature it serves is RETIRED in three packages with a drift gate. 3. Its own header calls the ask a one-shot favour and says not to spend it before knowing the design survives. It did not survive. Marked superseded, kept not deleted: the measurements in it — the silent no-op GRANT, the reserved membership, the NOINHERIT probe trap, the definer/bypassrls trap — are the expensive part, and a deleted dead end gets rediscovered. Edge function redeployed with the corrected note; boundary re-verified live after the redeploy (bogus creds -> 403, not 500). 83 migrations, all numbers unique. Co-Authored-By: Claude Opus 5 --- .../functions/broadcast-terminal/index.ts | 15 +- ...igrate_v0.82_r0_broadcast_insert_grant.sql | 40 ++++- ...3_authorize_broadcast_status_predicate.sql | 169 ++++++++++++++++++ 3 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 backend/supabase/migrate_v0.83_authorize_broadcast_status_predicate.sql diff --git a/backend/supabase/functions/broadcast-terminal/index.ts b/backend/supabase/functions/broadcast-terminal/index.ts index c5da6b8c..3d4b8599 100644 --- a/backend/supabase/functions/broadcast-terminal/index.ts +++ b/backend/supabase/functions/broadcast-terminal/index.ts @@ -43,10 +43,17 @@ // authorized to write a revoked session's topic, and this function — which // the paragraph above calls the entire write-side boundary — would let it. // -// Closing it means a migration adding a status/consent predicate to that -// RPC, which is owner-gated. Until then, do not describe revocation as -// server-enforced anywhere: it is client-enforced, with a server that does -// not disagree. +// HALF-CLOSED 2026-09-09 by migrate_v0.83: the RPC now also requires +// `rs.status in ('pending','running')`, so a session retired to +// status='stopped' — which is exactly what revocation posts — stops +// authorizing. Verified against real rows: the old predicate set matched 1, +// the new one matches 0. +// +// STILL OPEN: consent. `cloudShared` is an in-memory helper flag never +// mirrored to the database, so a session that was never shared but is +// running would still authorize if a helper asked. Closing that needs a +// consent column the client maintains. So revocation is now enforced on +// BOTH sides; consent is still client-only. Do not conflate them. // The READ side is unaffected and still RLS-governed (migrate_v0.81), so // subscribers are still restricted to their own sessions. // diff --git a/backend/supabase/migrate_v0.82_r0_broadcast_insert_grant.sql b/backend/supabase/migrate_v0.82_r0_broadcast_insert_grant.sql index 600bb455..f40c4eae 100644 --- a/backend/supabase/migrate_v0.82_r0_broadcast_insert_grant.sql +++ b/backend/supabase/migrate_v0.82_r0_broadcast_insert_grant.sql @@ -1,6 +1,44 @@ -- ============================================================ -- v0.82 — the one grant v0.81 could not issue, and the policy that needs it --- Date: 2026-09-08 · *** NOT APPLIED — REQUIRES A supabase_admin-CLASS ROLE *** +-- Date: 2026-09-08 · *** SUPERSEDED 2026-09-09 — DO NOT APPLY, DO NOT ASK *** +-- +-- ╔══════════════════════════════════════════════════════════════════════╗ +-- ║ READ THIS BEFORE OPENING A SUPABASE SUPPORT TICKET. Everything below ║ +-- ║ is still TRUE and still worth reading for the measurements — but the ║ +-- ║ ask it recommends is no longer the right one. ║ +-- ╚══════════════════════════════════════════════════════════════════════╝ +-- +-- Three things changed on 2026-09-08/09, in the order that matters: +-- +-- 1. THE WRITE PATH WAS BUILT WITHOUT THIS GRANT. The `broadcast-terminal` +-- edge function (deployed v1, 2026-09-09) takes v0.65's own recorded +-- fallback — helper → edge fn → service_role — and service_role ALREADY +-- holds INSERT on realtime.messages. Proven end to end before it shipped: a +-- service-role publish to a private topic is delivered; an anon publish +-- gets the same HTTP 202 and is silently dropped. So the grant this file +-- exists to obtain buys nothing the product needs. +-- +-- 2. THE FEATURE IT SERVES IS RETIRED. `RemoteSessionPlane.isEnabled = false` +-- in three packages now (Swift helper, CLIPulseCore, and as of 2026-09-09 +-- helper/remote_session_plane.py, with a drift gate across all three). The +-- app offers no remote terminals; nothing subscribes to `pterm:` on any +-- platform. Both producers are gated on that flag. +-- +-- 3. THE SUPPORT ASK WAS THE EXPENSIVE PART. This file's own header calls it +-- a one-shot favour and says not to spend it before knowing the design +-- survives. It did not survive: it was replaced (1) and then retired (2). +-- +-- WHAT TO DO IF THE PLANE IS EVER UN-RETIRED: start from the relay, not from +-- here. The custom-role design needs a privilege the project owner cannot +-- obtain and support may decline; the relay needs none and is already deployed +-- and tested. Re-read `broadcast-terminal/index.ts` — including its stated cost, +-- that service_role is rolbypassrls so the edge function IS the write-side +-- boundary — before reviving anything in this file. +-- +-- KEPT, NOT DELETED, for the same reason the retirement was: the measurements +-- below (silent no-op GRANT, reserved membership, the NOINHERIT probe trap, +-- the definer/bypassrls trap) are the expensive part and a deleted dead end +-- gets rediscovered. -- -- ⚠️ RUN AS `postgres` AND THIS FILE DOES NOT ERROR. It warns and does -- nothing. The assertion block at the foot turns that silence into an diff --git a/backend/supabase/migrate_v0.83_authorize_broadcast_status_predicate.sql b/backend/supabase/migrate_v0.83_authorize_broadcast_status_predicate.sql new file mode 100644 index 00000000..1fbc5ac0 --- /dev/null +++ b/backend/supabase/migrate_v0.83_authorize_broadcast_status_predicate.sql @@ -0,0 +1,169 @@ +-- ============================================================ +-- v0.83 — close the server half of R0 revoke +-- Date: 2026-09-09 +-- +-- ── WHAT IS WRONG ───────────────────────────────────────────── +-- `remote_helper_authorize_broadcast` is the ENTIRE write-side boundary for the +-- private terminal relay: `broadcast-terminal` runs as service_role, which is +-- rolbypassrls, so no RLS policy is consulted on that path. Its own header says +-- so, and then names this gap. +-- +-- The function authorizes on exactly four predicates: +-- rs.id = p_session_id +-- rs.device_id = p_device_id +-- rs.user_id = v_user +-- rs.realtime_private is true +-- There is no status predicate and no consent column. M4.4d's `cloudShared` is +-- an IN-MEMORY helper flag never mirrored to the database, and revocation +-- (`unshareAttachedSession` -> `retireMintedRow`) only posts `status='stopped'`, +-- which this function does not read. +-- +-- So after a user revokes sharing, the row survives with realtime_private=true +-- and the server keeps authorizing writes to that session's topic. Revocation +-- has been enforced entirely client-side, by a helper that could be stale, +-- buggy, or replaced. +-- +-- ── WHAT THIS CHANGES ───────────────────────────────────────── +-- One predicate: the session must not be in a TERMINAL state. +-- +-- and rs.status in ('pending', 'running') +-- +-- Deliberately an allowlist of live states rather than `<> 'stopped'`: a future +-- terminal state (say 'errored', which `RemoteSessionStatus` already defines) +-- would otherwise keep authorizing. And deliberately including 'pending' rather +-- than requiring 'running': a helper that begins broadcasting in the window +-- between row creation and the first status post would otherwise earn a 42501, +-- which the Swift sink treats as an authoritative denial and suppresses the +-- session for a 60 s backoff. Denying a live session is a worse failure than +-- briefly admitting a pending one. +-- +-- ── BLAST RADIUS, MEASURED 2026-09-09 ───────────────────────── +-- remote_sessions 3 rows +-- realtime_private = true 3 +-- status 'stopped' for all 3 +-- status = 'running' 0 +-- So this authorizes strictly less than before and denies nothing that is +-- currently allowed-and-live: there is no live session to break. The three +-- rows it newly refuses are exactly the class this migration exists to refuse. +-- +-- Two callers, both fine with the narrowing: +-- * `mint-realtime-token` — mints for a live session; a stopped one has no +-- terminal to mirror. +-- * `broadcast-terminal` — same, and it maps 42501 to 403, which the Swift +-- sink suppresses for a bounded backoff rather than permanently. +-- +-- ── NOT A SUBSTITUTE FOR CONSENT ────────────────────────────── +-- This closes the REVOKE gap, not the consent gap. `cloudShared` is still not +-- in the database, so a session that was never shared but is running would +-- still authorize if a helper asked. Closing that needs a consent column the +-- helper writes, which is a bigger change and a schema the client must +-- maintain. Recorded here so the next reader does not mistake this for the +-- whole fix. +-- +-- Body below is the LIVE definition read back with pg_get_functiondef on +-- 2026-09-09, plus the one predicate — not reconstructed from the repo, because +-- production function bodies drift. +-- +-- Runs as ONE transaction. +-- ============================================================ + +create or replace function public.remote_helper_authorize_broadcast( + p_device_id uuid, p_helper_secret text, p_session_id uuid +) +returns uuid +language plpgsql +security definer +set search_path to 'pg_catalog', 'public', 'extensions' +as $function$ +declare + v_user uuid; + v_owner uuid; +begin + v_user := public._remote_authenticate_helper_gated(p_device_id, p_helper_secret); + if v_user is null then + raise exception 'unauthorized' using errcode = '42501'; + end if; + + select rs.user_id into v_owner + from public.remote_sessions rs + where rs.id = p_session_id + and rs.device_id = p_device_id + and rs.user_id = v_user + and rs.realtime_private is true + -- v0.83: a retired/ended session must stop authorizing. Revocation posts + -- status='stopped'; without this the server kept saying yes. + and rs.status in ('pending', 'running'); + if v_owner is null then + raise exception 'session not authorized for private broadcast' using errcode = '42501'; + end if; + + return v_owner; +end; +$function$; + +-- ------------------------------------------------------------ +-- In-transaction assertions. Each ABORTS, and each would have FAILED against +-- the pre-apply body measured above. +-- ------------------------------------------------------------ +do $$ +declare def text; +begin + select pg_get_functiondef(p.oid) into def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'remote_helper_authorize_broadcast'; + + if def is null then + raise exception 'the function vanished'; + end if; + + -- Would have failed before: the predicate did not exist. + if def not like '%rs.status in (''pending'', ''running'')%' then + raise exception 'the status predicate is not in the deployed body'; + end if; + + -- The four original predicates must all survive. Narrowing is the point; + -- accidentally DROPPING one would widen authorization instead. + if def not like '%rs.realtime_private is true%' + or def not like '%rs.device_id = p_device_id%' + or def not like '%rs.user_id = v_user%' + or def not like '%rs.id = p_session_id%' then + raise exception 'an original predicate was lost — this would WIDEN authorization'; + end if; + + -- Shape must be unchanged: SECURITY DEFINER with a pinned search_path. + -- `create or replace` keeps these, but a future edit that retypes the header + -- could drop them silently, and this function is a write-side boundary. + if not exists ( + select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'remote_helper_authorize_broadcast' + and p.prosecdef + and p.proconfig::text like '%search_path=pg_catalog, public, extensions%' + ) then + raise exception 'SECURITY DEFINER or the pinned search_path was lost'; + end if; + + -- And EXECUTE must not have widened. anon reaching this would matter: the + -- helper paths are anon-reachable by design and gated on helper_secret, so + -- this only pins that the grant set did not CHANGE under us. + if has_function_privilege('anon', 'public.remote_helper_authorize_broadcast(uuid,text,uuid)', 'EXECUTE') + is distinct from true then + raise exception 'anon EXECUTE changed — the helper path is anon-reachable by design; re-read before proceeding'; + end if; +end +$$; + +-- ============================================================ +-- Post-apply, by hand — a green apply is not evidence: +-- select pg_get_functiondef(oid) from pg_proc +-- where proname='remote_helper_authorize_broadcast'; -- has the predicate +-- +-- -- The behaviour this exists for, with a real helper_secret (owner only): +-- -- a session retired to status='stopped' must now raise 42501 where it +-- -- previously returned the owner uuid. +-- +-- select count(*) filter (where status in ('pending','running')) as still_authorizable, +-- count(*) as total +-- from public.remote_sessions where realtime_private; +-- -- 2026-09-09: 0 / 3. Every private row is 'stopped', so this migration +-- -- newly refuses all three — which is the point, not a regression. +-- ============================================================