Skip to content

[pull] main from Stirling-Tools:main - #325

Merged
pull[bot] merged 5 commits into
5474312:mainfrom
Stirling-Tools:main
Sep 1, 2026
Merged

[pull] main from Stirling-Tools:main#325
pull[bot] merged 5 commits into
5474312:mainfrom
Stirling-Tools:main

Conversation

@pull

@pull pull Bot commented Sep 1, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

ConnorYoh and others added 5 commits September 1, 2026 08:56
…7729)

# Description of Changes

## The problem

The portal's query client was created per mount:

```ts
const [queryClient] = useState(createPortalQueryClient);
```

The portal is a route (`/processor/*`, a lazy element), and the switch
to the editor is a client-side `navigate()`. So leaving the processor
unmounts `PortalApp`, the client goes with the component, and the cache
goes with the client. Coming back refetches everything, whether or not
anything changed: four requests for the Users page alone (roster,
grants, teams, auth config), and 21 `useQuery` sites across the portal.

The editor's client sits above the router in `AppProviders` and survives
the same trip. The round trip only ever cost in one direction.

## The fix

The module already kept the instance in a module-level slot so
`tryGetPortalQueryClient()` could find it. It just replaced it on every
mount instead of reusing it, so the change is to create it lazily and
hand out the same one:

```ts
export function getPortalQueryClient(): QueryClient {
  current ??= new QueryClient({ defaultOptions: { queries: baseQueryOptions } });
  return current;
}
```

Still a separate instance from the editor's. The two namespace their
keys apart (`["portal", ...]` against `["editor", ...]`) and invalidate
independently, which this does not change.

## What this does not do

`gcTime` is 5 minutes, from the shared `baseQueryOptions`. An entry with
no observer is still collected on that timer, so this warms a quick trip
to the editor and back, not a return after a long editing session.
Raising the portal's `gcTime` is a separate decision and is not made
here.

## Why it is safe

**Signing out.** A cache that outlives a mount must not outlive a
session, because the portal's holds the admin roster, emails and roles.
Logout goes through `window.location.assign`, a full page load, so the
whole JS context is discarded and no cache can survive it. Nothing in
the codebase calls `queryClient.clear()` on sign-out, and nothing needs
to. If logout ever becomes a client-side navigation, this needs an
explicit reset, and `resetPortalQueryClient()` is the hook for it.

**The one caller of the null check.** `resolveTeam` in
`saas/portal/usersBackend.ts` uses `tryGetPortalQueryClient()` and falls
back to a direct fetch when there is no client, which its comment
describes as the unit-test path; the cache path is preferred because it
honours both `staleTime` and invalidation. A longer-lived client means
the preferred path is taken more often, not less.

## Testing

Three tests in `queryClient.test.tsx`, and the first two fail if the
client goes back to being created per call:

| | |
|---|---|
| A remount is served from cache rather than refetching | the behaviour
this changes |
| Every caller gets the same instance | the mechanism |
| No client is reported until the portal first mounts | the contract
`resolveTeam` reads |

The three existing portal caching suites called the factory expecting a
fresh client per case. They now call `resetPortalQueryClient()` in a
`beforeEach`, which is what keeps `sharing.test.tsx`'s "a later screen
refetches nothing" case honest rather than passing on a leaked cache.

`task frontend:check` passes typecheck, lint and oxfmt, and 2402 of 2404
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, are untouched here and fail the same way
on `main`.
…rive it (#7415)

Replaces the bare account-link login box with a guided Connect flow, and
wires up the triggers that actually put it in front of someone.
## Top bar 
<img width="1580" height="422" alt="image"
src="https://github.com/user-attachments/assets/719e12fc-121a-4caa-bc72-124c5167b011"
/>

## The modal

Three steps on the portal's own `FlowModal` + `StepModalHeader`, the
shells procurement and prepay already wear:

1. **What you unlock** — six benefits as a plain list.
<img width="817" height="503" alt="image"
src="https://github.com/user-attachments/assets/4644ddd2-6181-44e1-9be9-7a961972195d"
/>

2. **Sign in** — the existing `SupabaseLoginForm`, reseated.
<img width="880" height="930" alt="image"
src="https://github.com/user-attachments/assets/fc66cbbb-9f98-40a4-9daa-4f2447713f39"
/>

3. **Connected** — confirms, then deep links into Users, Pipelines and
Policies.
<img width="876" height="752" alt="image"
src="https://github.com/user-attachments/assets/28358e4d-a44f-4118-a8ae-8275984ebd00"
/>


Re-auth stays a single step with no pitch and no success screen.

## The triggers

**`LinkGate` stops being dead code.** It was built as the drop-anywhere
"link to unlock" wrapper and was imported by nothing. It is now a
blocking empty state that replaces the feature it guards, wired into
Pipelines, Policies, Users, Sources and Integrations.

**Scoped to creating and editing, never viewing.** Existing pipelines,
policies, sources and connections keep listing and running, so upgrading
an unlinked instance cannot take away something that already works. The
clicks that would open a builder or a create modal ask for the
connection first, which is the moment an admin has already declared
intent.

## Capability signal

`accountLinkAvailable` on `/api/v1/config/app-config`. Gating needs two
facts: whether the instance is linked (`LinkContext`) and whether it
*could* be (this flag). The account-link endpoints 404 when the feature
flag is off, which the client cannot distinguish from "not linked yet" —
so gating on link state alone would lock all five views on every default
install with no way out. `useConnectGate` holds that decision in one
place and shares the app-config query key, so it costs no extra request.

Read from the environment rather than `AccountLinkProperties` because
`:core` cannot depend on `:proprietary`.
…o link (#7728)

Add CI steps to enable PR deploy servers to link to prod saas. This will
allow pr testing of payment flows, usage of real credits etc
Redesigns the policies system so that the backend has an understanding
of policies running over the Editor. The Editor is not set up as a
source for the backend because the backend can't actively get files from
it, they come in via the frontend sending them to the backend, so
instead pipelines have a specific editor key in them to encode whether
the pipeline is triggered on file upload/export in the editor.

Also make a big effort in the frontend code towards genericising policy
running. Previously, there was specific support in the main policy
executor for each policy that it had to run, which was not going to be
appropriate long-term, especially when users can run any pipeline in the
editor. There's more work needed here for me to really be happy with it
but this PR is plenty large on its own and moves it in the right
direction.

All of the above was required to allow arbitrary user pipelines to run
in the editor. This PR makes it so that the user can select Editor as a
source in the pipeline creator, along with whether it should run on
upload or export.

<img width="1437" height="506" alt="image"
src="https://github.com/user-attachments/assets/b2d176a1-185c-480b-9916-abdd1447d8e1"
/>

---------

Co-authored-by: James Brunton <james@stirlingpdf.com>
…hem (Review Flow PR 5a) (#7761)

Review Flow PR 5a — the first half of #7479, which stays open for
reference until both halves land. This PR is the ranking and the
bookkeeping; #7762 adds the retry handlers. Merging both reproduces
#7479's diff byte-for-byte.

## What's added

**The action slot model (backend).** `FailureActionSlot` ranks each of a
kind's offers as its `RESOLUTION`, `SECONDARY` or `OVERFLOW`.
`FailureKind` now declares placement per offer — the password-protected
kind names `DECRYPT_AND_RETRY` as its resolution, `UNKNOWN` leads with a
plain `RETRY` — and `FailureActionId` gains those two ids. The
declarations are data; their client handlers arrive in the follow-up, so
this build withholds them with a reason rather than rendering unwired
buttons (the same forward-compatibility #7478 relied on).

**A resolve transition.** `POST /api/v1/notifications/{id}/resolved`
lets a client report a failure fixed. `NotificationSource.parse` turns a
qualified notification id back into the source that owns it, and
`FileRunEventService` folds the resolution into the incident rather than
deleting it.

**`viewerReviewsTeam` on the list response.** A member sees only rows
whose document this browser holds — they can neither open nor fix
anything else — while a team reviewer keeps every row.

**The bell renders the ranking** (`promoteActions`): one primary button,
at most one secondary, the rest in an overflow menu beside **Copy log**.
The row's body is the kind's own sentence; the raw failure message moves
into the menu.

**Read state is a timestamp, not a row id.** `readThroughAt` replaces
`lastSeenId`: when a resolved or dismissed row leaves the list, the rows
below it stay read instead of re-lighting the badge.

## How to test

Needs a proprietary or SaaS build with login enabled (`task dev:all`,
sign in).

1. **Create a failure.** Add a password-protected PDF to the editor and
choose **Skip for now**; the upload's policy run fails on it.
2. **Open the bell.** The row reads the kind's sentence, not a stack
trace. Its primary button is **View file** — the server offers Decrypt
and retry as the resolution, but this build withholds it (handler lands
in the follow-up), so the best renderable offer is promoted instead.
3. **Open the row's ⋯ menu.** View in processor and Dismiss sit there,
along with **Copy log**, which copies the raw message.
4. **Check the read marker survives a departure.** With two failures,
open the bell (badge clears), dismiss the newer row, and refresh: the
badge stays dark. On main, the marker held the departed row's id and the
older row re-read as unread.
5. **Member visibility.** As a plain member, a failure recorded from
another browser does not appear in the bell; as a team reviewer it does.
6. **Resolve endpoint.** `POST
/api/v1/notifications/failure-{eventId}/resolved` as the owner removes
the row on the next poll; `NotificationResolveTest` pins refusal for a
non-owner, an unknown id, and a foreign prefix.

## Migration

None.
@pull pull Bot locked and limited conversation to collaborators Sep 1, 2026
@pull pull Bot added the ⤵️ pull label Sep 1, 2026
@github-actions github-actions Bot added Back End Issues or pull requests related to back-end development Docker Pull requests that update Docker code Front End Issues or pull requests related to front-end development Java Pull requests that update Java code Translation Issues or pull requests related to translation API API-related issues or pull requests Test Testing-related issues or pull requests GitHub Issues or pull requests related to GitHub configuration and integrations labels Sep 1, 2026
@pull
pull Bot merged commit f6661a8 into 5474312:main Sep 1, 2026
28 of 32 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

API API-related issues or pull requests ⤵️ pull Back End Issues or pull requests related to back-end development Docker Pull requests that update Docker code Front End Issues or pull requests related to front-end development GitHub Issues or pull requests related to GitHub configuration and integrations Java Pull requests that update Java code Test Testing-related issues or pull requests Translation Issues or pull requests related to translation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants