diff --git a/deploy/2026.08.27.02.md b/deploy/2026.08.27.02.md new file mode 100644 index 00000000..50043c0f --- /dev/null +++ b/deploy/2026.08.27.02.md @@ -0,0 +1,78 @@ +# 2026.08.27.02 + +Everything in `2026.08.27.01`, which was **refused before any host was contacted** — its plan named no +commands and claimed "no schema change", which was false. This plan replaces it. `2026.08.27.01` stays +claimed and undeployed; do not try to revive it. + +Plus one fix made because of that refusal: a single 401 no longer signs the owner out of every tab. + +## The schema question, answered per host + +`officer_db/src/dav/schema.ts` was **deleted** — `dav_app_passwords` moved into `plugins/caldav`. +`gen-plugin-schemas` follows plugin DIRECTORIES, so the table is only declared on a host that has +`plugins/caldav/`. Everywhere else `bun db:push` will propose **dropping** it. + +Surveyed 2026-08-28, every host: + +| Host | `plugins/caldav/` | `dav_app_passwords` | +| --- | --- | --- | +| `alpha` | present | **8 rows** | +| `fofos` | absent | present, 0 rows | +| `macbookpro` | absent | present, not read (bun at `/opt/homebrew/bin`) | +| `macbruno` | absent | present, 0 rows | +| `production-pertento-bridge` | absent | present, 0 rows | +| `edge-pertento-bridge` | absent | present, 0 rows | +| `deadpool-rezio-bridge` | absent | not read (ssh refused at survey time) | + +So: **alpha pushes normally.** Everywhere else the table is a leftover of a feature that has left core, +and it is empty — but it is dropped **explicitly, with a guard**, not as a `db:push` side effect. + +`officer_marketplace` and its new `tags` column: **no host runs `officer-marketplace`** — checked, zero +of seven. Nothing to apply. If that changes, the column has no push script and must be added by hand. + +## Every host + +1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.27.02` +2. **Only if `plugins/caldav/` is ABSENT on this host** — retire the orphan table, refusing to if it + holds anything: + ``` + psql "$POSTGRES_URL" -Atc "select count(*) from dav_app_passwords" + # 0 → drop it: psql "$POSTGRES_URL" -c "drop table dav_app_passwords" + # anything else → STOP. Report the count and do not continue on this host. + ``` + Read the variable name from `.env` — several hosts use `OFFICERDEV_POSTGRES_URL`. +3. `bun db:push < /dev/null` — **stdin closed on purpose**, so a push needing an answer fails instead of + hanging. Expect **no changes** after step 2. Anything destructive: stop and report. +4. `bun run plugins:update` +5. `pm2 restart officer` +6. `pm2 restart officer-opencode` — this release rewires how OpenCode gets its skills, its agent identity + and its credential, and all of that is built when the sidecar boots. Without this the code ships and + nothing about OpenCode changes. + +**`alpha`:** skip step 2 entirely — it has the plugin and 8 real credentials in that table. Step 3 runs +normally and must report no changes. + +**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip +with a note. + +**`deadpool-rezio-bridge`:** login is `br`, checkout `/home/br/officerdev/platform`. SSH refused once +during the survey — if it refuses again, note it and move on rather than retrying in a loop. + +**Do not restart `officer-claude-code`.** It ends every live agent session on the host, nothing here needs +it, and it is the owner's call. + +## Risk + +Moderate, and concentrated in step 2 — the only destructive command in this release. It is guarded by a +count and it is skipped on the one host where the table has contents. + +After the rollout, two things are worth a look: `useThemeAsset` now reads in `requestAnimationFrame`, so +a themed logo or wallpaper failing to appear points here; and the notify router's mount order changed, so +device registration is worth one check (it forwards correctly on `officer-dev`). + +## Hosts + +alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge · +deadpool-rezio-bridge + +`officer-dev` is skipped: it is the host running the rollout. diff --git a/package.json b/package.json index a6356af4..0a52016a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "officer", - "version": "2026.08.27.01", + "version": "2026.08.27.02", "private": true, "type": "module", "workspaces": [ diff --git a/src/workspaces/hooks/src/useClient.ts b/src/workspaces/hooks/src/useClient.ts index df34eb85..32f85e79 100644 --- a/src/workspaces/hooks/src/useClient.ts +++ b/src/workspaces/hooks/src/useClient.ts @@ -206,6 +206,45 @@ const handleRevokedSession = (url: string) => { if (revocationHandled) return; revocationHandled = true; + /* + Guard 2b: CONFIRM the session is dead before destroying it. + + A 401 from any one endpoint used to be enough to wipe the token out of both storages and reload — + which signs the owner out of every tab, and keeps them signed out, because there is then no token + anywhere to recover from. That is the right response to a revoked session and far too violent a + response to one endpoint answering 401 for its own reasons. + + Reported 2026-08-27, mid-demo: every refresh and every new tab came back signed out, with nothing in + the logs and a freshly signed token working fine against every route the app loads. One transient + 401 is indistinguishable from a revocation at the call site — so ask the one endpoint whose whole + job is to answer it. + + `/auth/me` is exempt from this handler (the `/auth/` guard above), so this cannot recurse. + */ + void (async () => { + try { + // Derived from the URL that just failed, because the base is passed per call rather than held in + // config — a relative guess would be wrong on any host that mounts the API elsewhere. + const probe = /\/api\//.test(url) ? url.replace(/\/api\/.*$/, '/api/auth/me') : '/api/auth/me'; + const res = await fetch(probe, { headers: { Authorization: `Bearer ${theToken}` } }); + // Still good: that 401 belonged to one endpoint, not to the session. Leave everything alone and + // let the next revocation try again. + if (res.ok) { + revocationHandled = false; + return; + } + } catch { + // The server is unreachable — a restart, a dropped network. Emphatically not a revocation, and + // wiping the session because the box was rebooting is the failure this guard exists to stop. + revocationHandled = false; + return; + } + destroySession(); + })(); +}; + +/** Actually end it: forget the token everywhere a load could read it back, then reload once. */ +const destroySession = () => { theToken = null; // Every key `createClient` reads, or the next load signs straight back in with the dead token. for (const key of ['PERTENTO_EDITOR_AUTH_TOKEN', 'BEARER_TOKEN']) {