Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c4af31e
fix(browse): report an import job Studio stopped following as outcome…
dawsontoth Oct 14, 2026
3d29838
fix(browse): stop following an import job the polled node does not ha…
dawsontoth Oct 14, 2026
c6630f2
docs(browse): record that an ERROR Studio never read reads as unknown…
dawsontoth Oct 14, 2026
1f93df1
fix(browse): read the clock once per import-job poll
dawsontoth Oct 14, 2026
ac4e62e
fix(browse): let Next past a remembered last page once the table's co…
dawsontoth Oct 14, 2026
480597b
fix(applications): settle an editor save after the page is gone, and …
dawsontoth Oct 14, 2026
7d1b955
fix(applications): clear the open file's undo history when it is repl…
dawsontoth Oct 14, 2026
d689810
feat(browse): ask in a Studio dialog instead of the browser's confirm()
dawsontoth Oct 14, 2026
6ad4ba2
fix(browse): keep a disabled Cleanup Orphan Blobs from running
dawsontoth Oct 14, 2026
02dbf85
fix(browse): don't offer Drop Table for a table the map no longer holds
dawsontoth Oct 14, 2026
6b8e6a9
fix(browse): leave a dropped table even when it is named like an Obje…
dawsontoth Oct 14, 2026
81ee118
fix(browse): read the schema maps by name through own-key helpers only
dawsontoth Oct 14, 2026
c419cb9
fix(browse): build the schema-relationship map without a prototype
dawsontoth Oct 14, 2026
a472f73
fix(cluster): log a removed self-hosted cluster's sign-ins out of its…
dawsontoth Oct 14, 2026
0b14c61
fix(auth): keep a sign-in made while a best-effort logout was still out
dawsontoth Oct 14, 2026
912c420
fix(auth): keep sign-ins another tab added when this tab writes its m…
dawsontoth Oct 15, 2026
dc715f0
fix(auth): store each sign-in marker under its own key
dawsontoth Oct 15, 2026
574b8c9
fix(auth): keep the sign-in marker migration from repeating, or faili…
dawsontoth Oct 15, 2026
2c43658
fix(auth): treat failed checks and /User/current answers as checks
dawsontoth Oct 15, 2026
50eb684
fix(auth): drop a passing app check's answer when a sign-in replaced it
dawsontoth Oct 15, 2026
e7ae19f
fix(auth): make a /User/current sync a check, not a sign-in
dawsontoth Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -784,8 +784,8 @@ Three traps when reading this from RUM data:

A boot-time redirect does **not** cost you that first view, and the deciding factor is whether
`getAllConnections()` can answer synchronously. It synthesizes `{ user: null, isLoading: false }`
for `OverallAppSignIn` only when the `Studio:PotentiallyAuthenticated` localStorage record has no
entry for it; with an entry it returns the record untouched, so the key is simply **absent**.
for `OverallAppSignIn` only when no `Studio:PotentiallyAuthenticated:OverallAppSignIn` marker is
stored; with one it returns the record untouched, so the key is simply **absent**.
`dashboardLayout.beforeLoad` guards on `auth && !auth.isLoading && !auth.user`, so those are two
different outcomes: a signed-out deep link redirects _during_ the router's initial load and the root
component never commits the deep-link location — one `startView`, named `/sign-in/` — while an
Expand Down
38 changes: 31 additions & 7 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ This file collects architecture and design notes for studio that aren't otherwis

- [`src/features/cluster/DESIGN.md`](src/features/cluster/DESIGN.md) — when the Starting-up and Scaling screens may report done, how clone progress is read, where the cluster card's sync chip gets its data, how custom-domain cert intent, certificate progress and the Bind selection work, why a domain change is built from a fresh read, which pages a terminated cluster still opens, and when its sign-ins are forgotten.

- [`src/features/instance/databases/DESIGN.md`](src/features/instance/databases/DESIGN.md) — when the browse grid may show its spinner, why a table without a primary key can't be listed, why a filtered list pages without a count, why Export CSV writes the file in the browser instead of asking Harper for `text/csv`, where columns for undeclared properties come from, and why a filter no index can answer asks first.
- [`src/features/instance/databases/DESIGN.md`](src/features/instance/databases/DESIGN.md) — when the browse grid may show its spinner, why a table without a primary key can't be listed, why a filtered list pages without a count, why Export CSV writes the file in the browser instead of asking Harper for `text/csv`, where columns for undeclared properties come from, why a filter no index can answer asks first, and why an import job Studio stopped following reads as outcome unknown.

- [`src/features/instance/applications/DESIGN.md`](src/features/instance/applications/DESIGN.md) — what happens to a path's unsaved edits, loaded contents and Monaco model when its file is deleted, replaced or moved; when a rolling deploy is done: following its restart job, and why a job Studio can't follow is never a success; and when a live deploy may be sent again.
- [`src/features/instance/applications/DESIGN.md`](src/features/instance/applications/DESIGN.md) — what happens to a path's unsaved edits, loaded contents and Monaco model when its file is deleted, replaced or moved; how an editor save settles after the page is gone, one file at a time; when a rolling deploy is done: following its restart job, and why a job Studio can't follow is never a success; and when a live deploy may be sent again.
- [`src/features/instance/config/roles/DESIGN.md`](src/features/instance/config/roles/DESIGN.md) — why the role editor's JSON text is the only source of truth, what forces the JSON view, and what Save changes on purpose.
- [`src/router/DESIGN.md`](src/router/DESIGN.md) — why a failed route load is reported and shown only by its route, and why a preload still toasts.

Expand Down Expand Up @@ -72,6 +72,7 @@ Every `getInstanceClient` client replays a 502/503/504 up to three times (5s/10s
The same rule decides what a failure says. [`isUnansweredWrite`](src/integrations/api/unansweredWrite.ts) is a request `isReadRequest` rejects, answered 502/503/504 or not at all (`ERR_NETWORK`, a timeout). [`describeError`](src/react-query/queryClient.ts) checks it before reading any body and returns "Outcome unknown … check whether it took effect before you try again", so the global mutation toast and every `describeError` caller say so with no copy of their own. Gate on status, never body (a gateway's body describes the gateway), and keep it to display: rewriting `error.message` would defeat `shouldKeepEvent`, which recognises timeouts and `Network Error` by message.

- **A caller that adds its own failure toast to a mutation** asks [`shouldToastOwnFailure`](src/react-query/queryClient.ts) first, so "Failed to …" never sits beside the global warning. A caller with no mutation behind it (rename, the chat tools), or one whose mutation opts out of the global toast (the deploy mutation behind import, redeploy and staged-release activation), shows `describeError`'s text itself, under a title from [`deployFailureTitle`](src/features/instance/applications/modals/reportDeployHealth.ts) for a deploy ("Couldn't confirm … was redeployed", never "Failed to …"), and a loop that tallies one write per target (cluster restart, rolling config, licenses) counts these apart from failures.
- **Every error that means an unknown outcome is listed once**, in `describeUnknownOutcome` ([`queryClient.ts`](src/react-query/queryClient.ts)): this one, an unconfirmed restart (below), and an import job Studio stopped following ([databases DESIGN.md](src/features/instance/databases/DESIGN.md)). `describeError`, `shouldToastOwnFailure` and the toast's never-collapse rule (below) all read it, so a kind added there gets all three.
- **Do not re-wrap an AxiosError into a plain `Error`** for a write: the predicate needs its `config`. `secrets.ts` passes an unanswered one through as is.
- **A restart with no verdict stays marked until its target answers again or the watch gives up**, as an accepted one does, because a gateway can lose the answer to a restart that ran. The Restart button holds the mark while it watches, and the cluster loop waits on that member before restarting the next.
- **These toasts are never collapsed** onto a shared id: each warns about a different write, and a read timing out alongside one must not replace it through `request-timeout`.
Expand Down Expand Up @@ -108,9 +109,32 @@ Central manager only ever sends the boolean, never the other organizations' ids;
its own route: TanStack resolves it against the _current_ location, so a row that outlives a pending
navigation re-resolves its href one level too deep (HarperFast/studio#1710, #1711).
`EntityMenuItem.to` accepts only the helper's `AbsolutePath`, so a relative menu target fails `tsc`.
- **Remembered instance/cluster sign-ins are checked on demand**: `Studio:PotentiallyAuthenticated` spans every
organization ever visited, so the root auth subscription checks only the app sign-in; an entity is checked by
- **Remembered instance/cluster sign-ins are checked on demand**: the sign-in markers span every organization ever
visited, so the root auth subscription checks only the app sign-in; an entity is checked by
`useInstanceAuth`/`listenToEntity` or the instance-layout guard (`entityAuthCheckScope.test.ts`, HarperFast/studio#1329).
- **Each sign-in marker has its own key, `Studio:PotentiallyAuthenticated:<entityId>`, holding the URL signed into**
(`signInMarkers.ts`, HarperFast/studio#1968). A tab writes only the entity it signs in or out, so tabs can't erase or
restore each other's markers; app and full sign-outs act on every stored marker, at each URL an entity is marked at
here or in storage. A check never undoes another tab's change: a passing one of a sign-in this tab already marked
writes nothing, and a failing one removes only the marker it checked. So a marker removed elsewhere stays removed
while this tab still holds that sign-in, until this tab signs into the entity again, which always writes; for the
app sign-in, which every `/User/current` answer re-signs, those answers count as checks. A check's answer, or a
`/User/current` answer, is dropped when this tab signed in again while it was out (`crossTabMarkers.test.ts`,
`getCurrentUser.test.ts`). A sign-in storage refuses to record still signs in, and its next sign-in or check writes
it; a marker storage won't remove is retried at every marker write, unless another tab has rewritten it since
(`localSignOutStorageFailure.test.ts`).
- **The previous release's one shared record is kept in step for one more release.** Its tabs read
`Studio:PotentiallyAuthenticated` once at load and write their whole copy back, and know nothing of the per-entity
keys. So this release copies that record into the keys once per storage (a marker already stored per entity wins),
mirrors every marker change into it, and reads it in every sign-out sweep. A previous-release tab, or a rollback,
therefore still sees this release's sign-ins and sign-outs, and an app sign-out here reaches a sign-in only that tab
recorded. Its sign-in reaches this release's keys only at that first copy, and its sign-out of an entity doesn't
remove the entity's key, which matters only if its logout also failed; its full sign-out wipes both. The copy is
never repeated, because that tab's whole-copy writes would bring back what this release signed out, so the sign-out
wipe keeps the flag that records it. Until the copy lands, as when a full quota refuses it, the record also seeds
this tab's markers (`legacySignInMarkers.test.ts`, `account-menu.anon.spec.ts`). Every load also removes a record
that isn't one, which the previous release can't start on, and drops entries that aren't markers; a sign-in leaves
one it can't read alone. The release after this one stops the mirror and removes the record (HarperFast/studio#1970).
- **`Dashboard` renders nothing under it without a signed-in user.** Route data loads in parallel with the page load's
`/User/current` check (waiting for it costs +262/680 ms p75 on live org/cluster cold loads), and central manager
answers a request with no session with 403, so a deep link on an expired session has its route data fail before the
Expand All @@ -131,9 +155,9 @@ Central manager only ever sends the boolean, never the other organizations' ids;
from their next request. A tab follows only when the name is its own app user: a late 401 or event from user U's session must not sign out a
tab that has since signed in as V. A tab whose check is still out remembers the user, and if the check comes back as
them, asks again: central manager may have confirmed them just before the session ended.
Tabs follow that key, not the marker record, because a tab also drops the app sign-in from the record when its own
check merely fails (a timeout, a 5xx). A tab that follows writes no `localStorage`: its copy of the markers is a
snapshot from its own load, and writing it back could undo the other tab's next sign-in (`crossTabSignOut.test.tsx`).
Tabs follow that key, not the app sign-in's marker, because a tab also drops that marker when its own check merely
fails (a timeout, a 5xx). A tab that follows writes no `localStorage`: the other tab already wiped it, and a write
could undo that tab's next sign-in (`crossTabSignOut.test.tsx`).
- **Data fetching**: TanStack React Query 5. Query keys must be instance-scoped when the request targets a specific instance.
- **Polling**: every `refetchInterval` on a query that can reject against an instance or a cluster goes through [`pollUnlessForbidden`](src/react-query/pollUnlessForbidden.ts), which stops the timer on a 403 and backs off a sustained 400 or 5xx; a raw number polls a failing endpoint at full rate for as long as the page is open. When the interval depends on the data, compute it and hand it over per tick: `(query) => pollUnlessForbidden(base(query))(query)` (the back-off state is per query, not per wrapper).
- **Forms**: react-hook-form with a zod `zodResolver`. A form whose submit button waits on `formState.isValid` must not use the default `onSubmit` mode, which keeps `isValid` current but shows errors only after a submit the disabled button never allows, so the user is blocked with no reason given (HarperFast/studio#1550). Use `mode: 'onTouched'`: a field's error appears once it is first left and then follows each change, while `isValid` still tracks every keystroke (`onBlur` recomputes `isValid` only on blur, so the button lags the text). Enter neither submits past a disabled button nor leaves the field, so give the `<form>` `onKeyDown={revealFieldErrorOnEnter(form)}`, which treats Enter in a text input as leaving it. A refine that compares two fields reports on one of them; re-check that field from the other's `onChange` once it has been touched (`getFieldState(name).isTouched`, then `trigger(name)`), because `rules={{ deps }}` fires on the other field's first blur, before the user reaches it. react-hook-form only updates the `formState` fields a render has read, and `disabled={!isDirty || !isValid}` skips `isValid` while the form is pristine, so where one change can complete the form (a single field, a paste, a checkbox) the button stays disabled on a valid form. Read the fields before the JSX (`const { isDirty, isValid } = form.formState;`) and pin it with a test that makes one change and checks the button.
Expand Down
2 changes: 1 addition & 1 deletion e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Naming drives which project runs a spec: `*.anon.spec.ts` = no session,
- **Hash routing.** Routes are `/#/sign-in`, `/#/verifying`, `/#/<orgId>/users`.
- **No test-ids** in these flows — drive by role/label/text (`getByRole`, `getByLabel`).
The only `data-testid`s in the app are in instance analytics.
- **Auth is a cookie** (`POST /Login/`); the `Studio:PotentiallyAuthenticated`
- **Auth is a cookie** (`POST /Login/`); the `Studio:PotentiallyAuthenticated:<entity>`
localStorage flag is only a hint. `storageState` captures the cookie.
- **Sign-in and sign-up render every submit failure inline**
(`p[role="alert"][data-slot="form-message"]`). **Forgot-password renders only CAPTCHA rejections
Expand Down
56 changes: 52 additions & 4 deletions e2e/tests/account-menu.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { appSignInMarker, rememberAppSignIn } from './signInMarkers';

const role = (name: string) => ({ organizationName: name, role: 'owner', permission: { super_user: true } });
const user = {
Expand All @@ -18,9 +19,7 @@ test.beforeEach(async ({ page }) => {
['fetch', 'xhr'].includes(route.request().resourceType())
? route.fulfill({ status: 404, json: { error: 'Unmocked test request' } })
: route.continue());
await page.addInitScript(() =>
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }))
);
await rememberAppSignIn(page);
await page.route('**/User/current', route => route.fulfill({ json: user }));
await page.route('**/Organization/*', route => {
const id = route.request().url().split('/').at(-1)!;
Expand Down Expand Up @@ -87,7 +86,56 @@ test('account menu groups profile, organization switching, appearance and sign o
await page.getByRole('menuitem', { name: 'Sign Out', exact: true }).click();
await expect(page).toHaveURL(/#\/sign-in/);
expect(signedOut).toBe(true);
expect(await page.evaluate(() => localStorage.getItem('Studio:PotentiallyAuthenticated'))).toBeNull();
expect(await page.evaluate((key) => localStorage.getItem(key), appSignInMarker)).toBeNull();
});

test.describe('alongside a tab still on the previous release, which keeps every sign-in in one record', () => {
const legacyRecord = 'Studio:PotentiallyAuthenticated';

test('stays signed in on the sign-in that release recorded', async ({ page }) => {
await page.addInitScript(({ marker, legacy }) => {
if (!localStorage.getItem('Studio:PotentiallyAuthenticatedMigrated')) {
localStorage.removeItem(marker);
localStorage.setItem(legacy, JSON.stringify({ OverallAppSignIn: 'fixture' }));
}
}, { marker: appSignInMarker, legacy: legacyRecord });

await page.goto('/#/org-a');

await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();
expect(await page.evaluate((key) => localStorage.getItem(key), appSignInMarker)).toBeTruthy();
});

test('signs out of a server only that tab signed into', async ({ page }) => {
const oldTabSignIn = 'https://ins-old.example.test:9925/';
const instanceRequests: string[] = [];
await page.route(/ins-old\.example\.test/, route => {
instanceRequests.push(`${route.request().method()} ${route.request().postData() ?? ''}`);
return route.fulfill({
json: { message: 'ok' },
headers: {
'Access-Control-Allow-Origin': route.request().headers()['origin'] ?? '*',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Headers': 'content-type',
},
});
});
await page.route(/\/Logout\/?$/, route => route.fulfill({ json: {} }));
await page.goto('/#/org-a');
await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();
// That tab writes its whole record back after this one has loaded.
await page.evaluate(
({ legacy, url }) =>
localStorage.setItem(legacy, JSON.stringify({ OverallAppSignIn: 'fixture', 'ins-old': url })),
{ legacy: legacyRecord, url: oldTabSignIn },
);

await page.getByRole('button', { name: 'Account menu' }).click();
await page.getByRole('menuitem', { name: 'Sign Out', exact: true }).click();

await expect(page).toHaveURL(/#\/sign-in/);
expect(instanceRequests).toContain('POST {"operation":"logout"}');
});
});

test('account controls remain reachable on a narrow screen with keyboard focus return', async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/auth.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const password = process.env.PLAYWRIGHT_USER_PASSWORD;

/**
* Logs in through the real UI once and persists the session (the cookie set by
* POST /Login/ — the localStorage `Studio:PotentiallyAuthenticated` flag is only
* POST /Login/ — the localStorage `Studio:PotentiallyAuthenticated:*` flags are only
* a hint, so cookie-bearing storageState is what actually authenticates).
*
* Doubles as the sign-in happy-path smoke test.
Expand Down
5 changes: 2 additions & 3 deletions e2e/tests/cluster-form.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { rememberAppSignIn } from './signInMarkers';

const region = { id: 'us-1', region: 'US', latencyDescription: '83ms', instanceCount: 2 };
const plan = {
Expand Down Expand Up @@ -33,9 +34,7 @@ const organizationWith = (clusterRecord: typeof cluster) => ({
});

test.beforeEach(async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }))
);
await rememberAppSignIn(page);
await page.route(
'**/*',
route =>
Expand Down
5 changes: 2 additions & 3 deletions e2e/tests/clusters.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { rememberAppSignIn } from './signInMarkers';

const clusters = [
{ id: 'clu-production', name: 'Production', status: 'RUNNING', regionId: 'reg-east' },
Expand Down Expand Up @@ -26,9 +27,7 @@ const user = {
};

test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }));
});
await rememberAppSignIn(page);
await page.route('**/User/current', route => route.fulfill({ json: user }));
await page.route('**/Organization/org-fixture', route => route.fulfill({ json: organization }));
await page.route('**/Cluster/clu-production', route => route.fulfill({ json: { ...clusters[0], instances: [] } }));
Expand Down
5 changes: 2 additions & 3 deletions e2e/tests/global-search.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { expect, test } from '@playwright/test';
import { rememberAppSignIn } from './signInMarkers';

const role = (organizationName: string) => ({ organizationName, role: 'owner', permission: { super_user: true } });
test.beforeEach(async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }))
);
await rememberAppSignIn(page);
await page.route(
'**/*',
route =>
Expand Down
Loading
Loading