Appearance
Portal actions — port specs
Status: proposed, nothing here is implemented. Ports of the four C:\GitHub\stagehand-automations workers to this API (Playwright for .NET on Browserbase, no LLM). When an action ships, move what it actually does into docs/specs/BrowserActions-Spec.md and keep its spec here only for what's still open.
| Spec | Portal | Old worker | Old queue | State of the old lane |
|---|---|---|---|---|
| nexus-update-ecd.md | Auto & General "Unity" | apps/new-nexus-automation | NexusPortalUpdateECD | Live |
| repairline-sync-dates.md | Sedgwick RepairLine | apps/new-repair-line-dates-and-schedules | RepairlinePortalUpdateJobDates | Paused since 9 Oct 2026 (IP binding) |
| repairline-submit-job.md | Sedgwick RepairLine | apps/new-repair-line-submit-job | RepairlineSubmitJob | Paused since 9 Oct 2026 (IP binding) |
| endata-quote-rates.md | En-Data | apps/new-endata-quote-rates | EndataQuoteRates | Live, idle; sign-in blocked on MFA enrolment (work item 1745) |
Everything below is shared by all four actions.
Shape of an action
Each action is one handler. Two ways in:
- Queue consumer (normal path) — a
BackgroundServicereceives from Azure Service Bus and calls the handler. POST /actions/<portal>/<action>(operator path) — same handler, synchronous, same payload as the queue message plusdryRun?. Replaces the old workers'POST /run.
The handler: validate payload → business lookups (WebAPI) → if a browser is needed, bb.RunAsync against the portal's context → verify by reading back → return an outcome.
{ outcome: "complete" | "attempt" | "reject", reason: "<code>", sessionId: "<browserbase id>", detail?: {...} }complete— done (including "nothing to do"). Message completed.attempt— transient; retry with backoff. Message re-scheduled (see Queue).reject— the payload or the job can never succeed. Message dead-lettered.
dryRun: true does everything except the write (the old ALLOW_IRREVERSIBLE_WRITES=false), returning attempt/irreversible_write_refused. Used for the side-by-side run before cutover.
No selectors exist in the old code — every step was found at run time by the LLM. Each spec lists the visible labels the old prompts used; real locators must be captured from a live signed-in session before porting.
Queue: Azure Service Bus (proposed)
Replaces the SQL work queue (ethWorkQueue*_Preview tables/procs, n8n gateway, KEDA depth route) for these four lanes.
| Old work-queue feature | Service Bus equivalent |
|---|---|
| Claim + lease (1200 s) | Peek-lock; ServiceBusProcessor auto-renews the lock (MaxAutoLockRenewalDuration 20 min — lock max is 5 min per renewal) |
Concurrency limit 1 per portal (ethWorkQueueResource_Preview) | One queue per portal account, MaxConcurrentCalls = 1, one replica. RepairLine's two actions share one queue (repairline) and branch on the message Subject — that is what serialises the shared account. |
| Deduplication key | Duplicate detection on, MessageId = dedupe key. One window per queue, max 7 days. See each spec. |
Backoff (fnEthWorkQueueBackoffSeconds_Preview) | Not native — abandon redelivers immediately. On attempt: complete the message and send a copy with ScheduledEnqueueTime = now + backoff and an attempt app property; new MessageId (<key>:<n>) so duplicate detection doesn't eat it. Dead-letter past max attempts. |
| Dead letter | Native DLQ (reject, or max attempts) |
| Seal (at-most-once) | Not native. Only submit-job needs it — a row in a seal table owned by this repo, written before the irreversible step (see that spec). |
Pause (Paused on the queue row) | Set the queue to ReceiveDisabled, or a per-queue config flag the processor checks |
| Attempt table (operator's run log) | dbo.BrowserActionsRun (new, owned here): one row per handler run — queue, message id, key, outcome, reason, Browserbase session id, started/ended. The dead-letter digest reads this instead. |
| KEDA depth via n8n | Container Apps' native azure-servicebus KEDA scaler (managed identity), or just min replicas 1 |
Queues: nexus, repairline, endata.
What this costs outside this repo (decide before building):
- Producers are BlazorApps T-SQL (
spEthNexusECDQueueTasks,spEthRepairlineJobDatesQueueTasks,spEthRepairlineSubmitQueueTask) and each ends inEXEC spEthWorkQueueEnqueue_Previewinside its own transaction. T-SQL can't send to Service Bus transactionally. Proposed: BlazorApps changes them toSELECTthe eligible rows instead of enqueueing; n8n runs the proc on its schedule and posts each row toPOST /queues/<queue>/messageson this API (built; RepairLine sync-dates uses queuesbq-repairlinedates-prod-australiasoutheast-001, see BrowserActions-Spec.md), which sends it to Service Bus (n8n holds only the API key, no Service Bus credentials). This is a BlazorApps change — raise it there, don't edit those procs here. The Endata enqueue is already an n8n workflow (EXEC spEthWorkQueueEnqueue_Previewdirectly) and just switches toPOST /queues/<endata queue>/messages. - The submit-job create workflow seals in SQL (
spEthWorkQueueSeal_Preview @MessageId, @ClaimTokenbeforejob/createJob). It needs a version without the seal step; the API seals instead. - Dead-letter digest, rerun, pause and redrive n8n workflows all read/write the SQL queue tables and need rewriting against
BrowserActionsRunand Service Bus. - The SQL queue stays for anything else that uses it until those lanes move; cut over one queue at a time.
Credentials
Config keys per portal, in user-secrets locally and Key Vault references in prod (__ env separator):
| Key | Old Key Vault secret |
|---|---|
Portals:Nexus:Username / :Password | kv-nexusauto-prod-001: nexus-username, nexus-password |
Portals:Nexus:MfaUrl / :MfaAuthHeader | nexus-mfa-service-url, nexus-mfa-service-auth-header |
Portals:RepairLine:Username / :Password | RepairLine app vault: repairline-username, repairline-password (in place: user-secrets) |
Portals:RepairLine:LoginUrl, :MfaServiceProviderName | in place in appsettings.json: https://repairline.com.au/Account/Login?ReturnUrl=%2F, Sedgwick. Replaces repairline-mfa-service-url / -auth-header — RepairLine codes are read from the WebAPI api/Mfa, not the n8n serve webhook. |
Portals:Endata:Username / :Password / :MfaUrl / :MfaAuthHeader | kv-endata-prod-001: endata-username, endata-password, endata-mfa-service-url, endata-mfa-service-auth-header |
Portals:<name>:ContextId | — new: the Browserbase context holding that portal's signed-in profile |
Portals:<name>:Proxy | — new, optional: external proxy (server, username, password) when a portal needs a fixed IP |
WebApi:BaseUrl, WebApi:Username, WebApi:Password | kv-stagehand-prod-001: task-api-username, task-api-password (Basic auth) |
ServiceBus:Namespace | — new; managed identity, no connection string |
RepairLine:CreatePrimeJobUrl, :CreatePrimeJobApiKey | submit worker's CREATE_PRIME_JOB_URL / _API_KEY |
Dropped: OPENAI_API_KEY, STEEL_*, WORK_QUEUE_*, RUN_API_KEY (the API's Api:Key covers manual runs), STATE_DIR.
MFA
Codes are ingested by n8n ("Keep MFA store up to date": shared mailbox + SMS → LLM extract → dbo.EthMFAAsAService, one row per provider). The API never sees TOTP secrets or mailboxes.
- RepairLine reads its row through the BlazorApps WebAPI:
GET {WebApi:BaseUrl}/Mfa/<Portals:RepairLine:MfaServiceProviderName>(Sedgwick), Basic auth →{ serviceProviderName, latestMfaCode, updatedAt }(UTC),404if no row. Backed by BlazorApps-ownedspEthMFAAsAServiceGetCode(BlazorApps PR #327). - Nexus and Endata use the n8n webhooks:
POST <MfaUrl>, no body,Authorization: <MfaAuthHeader>,Accept: application/json, text/plain, */*. Response{ latest_mfa_code, code_updated_at, seconds_remaining, digits, period };latest_mfa_codemay be an unquoted number — parse the raw text.
| Mode | Portals | Rule |
|---|---|---|
Delivered (SMS/email ingested into dbo.EthMFAAsAService by n8n) | Nexus (SMS), RepairLine (email) | Read code_updated_at before submitting credentials (the floor); after submitting, poll every 5 s for up to 150 s and accept only a code with code_updated_at strictly later. Reserve a send from the budget before submitting. |
Computed (TOTP from dbo.EthMFAOtpAccounts) | Endata | Accept if seconds_remaining >= 20, else wait for the next window. No send budget. |
Send budget. Submitting credentials sends a code, there's no resend, and each portal caps sends (3/hour, Nexus's shared with A&G staff). The old ledgers are JSON files on an Azure Files share; RepairLine's two lanes kept separate ledgers (2 + 1). Proposed replacement, owned by this repo:
- Table
dbo.BrowserActionsMfaSendBudget (Account PK, WindowStart datetime2, SendCount int) - Proc
spBrowserActionsMfaReserveSend @Account, @MaxSends, @WindowSeconds— one atomicUPDATE … OUTPUT; returns 0 when the budget is spent → outcomeattempt/send_budget_exhausted. Onerepairlineaccount covers both RepairLine actions.
During a side-by-side run the old worker's ledger can't see this one — split the budget (e.g. old 2, new 1) or run them in different hours.
Saved login. The point of the Browserbase context is to not sign in at all most runs. Each portal gets one context (Portals:<name>:ContextId, persist: true). A context persists the browser's user-data directory; whether that covers IndexedDB (Nexus's token lives there) is the first thing to prove in the Nexus spike. Only one session may persist a context at a time — guaranteed by MaxConcurrentCalls = 1 on the portal's queue; manual POST /actions/... runs must take the same per-portal lock.
Changes to Browserbase.cs these specs need
RunAsync(..., contextId, persist, proxy?, timeoutSeconds?)— external proxy option (proxies: [{ type: "external", server, username, password }]) for fixed-IP portals, and an explicit sessiontimeout(900 s; the old deadline was 840 s inside a 900 s session).- Return the session id with the result (for
BrowserActionsRunand the outcome). - Per-portal route blocks via
page.RouteAsync(e.g. Nexusclear-site-data).
Not ported: response-cache.ts (served 304s from a file share to cut Steel residential-proxy GB) — add back if Browserbase proxy bandwidth shows up on the bill. App Insights spans — the Browserbase recording plus BrowserActionsRun covers it until it doesn't.
Hosting requirements
- One always-available replica (or KEDA scale-to-zero on the Service Bus scaler). Never more than one replica while concurrency is per-process.
- Outbound to Browserbase, the WebAPI, n8n, Service Bus, and SQL (for
BrowserActionsRun/ send budget / seal — the database firewall currently admits only the n8n host, so this host's egress IP must be added, or those three tables move behind n8n too).

