Skip to content

release: to prod#1578

Merged
joelorzet merged 31 commits into
prodfrom
staging
Jun 17, 2026
Merged

release: to prod#1578
joelorzet merged 31 commits into
prodfrom
staging

Conversation

@joelorzet

Copy link
Copy Markdown

No description provided.

sanbotto and others added 30 commits June 16, 2026 20:03
…her modal

When minting or revoking an org API key fails a role/MFA guard, the
client popped the dialog and either opened the Settings overlay or
routed to the step-up page. That drops the user onto an unrelated
modal mid-action. Surface the guard error as a toast and leave the
user on the current screen (the create dialog, or the key list after
the confirm dialog closes).
Manual and webhook runs already stamped executedWorkflowHash so a run
resolves to its workflow_history version by content hash. MCP calls and
all executor-driven triggers (scheduled, block, event) did not, leaving
those executions unlinked to any version.

Stamp the hash at every execution insert:
- MCP call route: hash the definition it loads to run.
- internal executions route: hash at phantom/direct-run creation.
- executor: hash the definition it actually loads, on both phantom
  upgrade and the no-phantom fallback insert (the run-time authority).

Update the phantom-upgrade test for the new helper argument and assert
the hash lands on the upgraded row.
The catch-all direct-execution route /api/execute/[...slug] (backing the
agent-callable execute_protocol_action MCP tool) sent protocol write actions
straight to writeContractCore, which signs and broadcasts a real tx from the
org wallet. Unlike every other direct write path (transfer, contract-call,
check-and-execute), it never enforced the plan execution limit, never checked
for a wallet, never reserved against the daily spend cap, and created no
directExecutions audit row -- so an API key could run unlimited, unmetered,
unlogged on-chain writes.

Wrap the write branch with enforceExecutionLimit + requireWallet +
checkAndReserveExecution and record the execution (markRunning + complete/
fail), mirroring contract-call. Reads stay ungated.
The session-minting path in /api/user/verify-ip compared the request IP to
the one pinned in the pending_ip_verify cookie with a !currentRawIp
short-circuit, so a null IP made ipMatch true. In production a null IP means
the request reached the origin without CF-Connecting-IP (direct-origin /
Cloudflare bypass), which let an attacker holding the cookie plus valid
factors finish the session mint on a foreign network, defeating the IP
binding this endpoint exists to enforce.

Extract the decision into isVerifyIpMatch and fail closed in production when
the IP cannot be resolved; local dev (no CF header) still passes.
The authorize page resolved the token's org from the cookie-derived active
session at click time and never showed which org the token would scope to. A
multi-org user who switched their active org in another tab between viewing the
page and clicking Approve would silently mint a token for an unintended org.

Render the resolved org on the consent screen ("Authorizing access to ..."),
bind its id into the approve form, and reject in handleApprove when the org
resolved at submit time no longer matches the one the user consented to
(isConsentOrgMismatch). A missing bound id stays backward compatible.
…/sign

On the Tempo (MPP) charge path the fund-moving amount and recipient live
inside the server-signed serialized challenge, while the outer caller-supplied
paymentChallenge fields are 0/empty. verifyWorkflowBinding only equality-checks
the outer fields (base-only), so an HMAC-compromised caller could (a) pair a
cheap cover-workflow slug with an expensive challenge for a different workflow
to under-reserve the daily cap (F-011), and (b) keep the outer amount at 0 so
the risk classifier saw 0 and never escalated a >=$50 charge to the ask/human
tier (F-012).

Deserialize the challenge up front, cross-check the inner amount + recipient
against the workflow binding (same equality the base/x402 path enforces), and
feed the verified inner amount to classifyRisk. The ask-tier approval binding
+ stored payload are enriched with the inner recipient/amount so the
/approve re-derivation stays consistent. Proof-intent challenges are unchanged.

Also stubs server-only in the sign-route integration suite so it loads under
the node test env (matches the unit-test convention).
…ir item

markOverageRecordsPaid stamped providerInvoiceId on EVERY billed,
unattributed overage record for an org whenever any invoice was paid -- so an
unrelated standalone or manual invoice could claim all pending overage, and
the follow-up scanAndCreateDebt early-return (invoice set + paid) then silently
waived it. An org could run large overage and clear it against a tiny payment.

Resolve each candidate record's invoice via the same getInvoiceForItem path
scanAndCreateDebt uses and stamp only the records whose item belongs to this
exact invoice; records with no item link are left unattributed for the debt
scan to re-resolve. The billing provider is threaded through from
handleBillingEvent rather than re-fetched.
…cles

Both POST /api/auth/finish-credential-signup and POST /api/auth/strict-signin/start
verify a credential password for any caller and return a distinguishable 401 vs
200 (strict-signin/start signs non-TOTP users straight in on a correct
password). Neither sits under the Better Auth catch-all, so neither inherited
rate limiting, leaving two unauthenticated password oracles.

Add a shared in-memory limiter (per-email 5/15m + per-IP 20/15m, mirroring the
forgot-password limiter) consulted before any password comparison on BOTH
routes. Buckets are shared so an attacker cannot get a fresh allowance by
hopping between the two endpoints for the same target.
…s-on-screen

fix(ui): keep API key dialog on guard failure instead of opening another modal
The version timeline (GET /history) and historical-snapshot fetch
(GET ?version=N) were gated on isOrgAdmin, so a regular member got 403
on the right-sidebar History tab of a workflow they already have full
access to. Version history is the edit history of that workflow, not
the org-wide security audit, so any current org member with full access
can read it. The org activity/audit feed stays admin/owner only.

Drop the isOrgAdmin requirement from both routes, keeping the existing
org-membership (hasFullAccess) gate.
…ures

Pre-broadcast gating returns (plan limit, wallet, spend cap) were recorded as
success or failed via protocolActionDisposition, freezing the error on the
idempotency key so a retry replayed it even after the cause was resolved. Thread
the idempotency outcome into executeProtocolAction and record each return
explicitly: gating failures release, broadcast results finalize as success or
failed. Mirrors the contract-call route.
…n-linkage

fix(executor): stamp workflow version hash on every execution trigger
…ccess

fix(api): let org members read a workflow's version history
feat: allow using API key for Blockscout (KEEP-806)
…p-fails-open-when-cf-connecting-ip-absent-ip

fix(security): fail closed in verify-ip when client IP is unresolved
The Activity nav item opened the org-wide security audit in an overlay.
Point it at a real route (/activity) so it deep-links and behaves like
Analytics/Earnings. The page reuses the existing audit hook, filter bar,
and feed; the ActivityOverlay component stays in place for any other
callers. Admin/owner only, with a gate for non-admins who hit the URL
directly.
…separators

- ActivityFeed gains opt-in fillHeight (flex column, list scrolls, pager
  pinned to the bottom) and syncPageToUrl (?page=N seed + reflect), both
  off by default so the overlay and per-resource feeds are unchanged.
- usePaginatedResource accepts initialPage and skips the first reset so a
  URL-seeded page survives mount.
- Activity page chrome is a bounded full-height flex column so the pager
  sits at the very bottom.
- Date groups are the separators (underlined section headers); rows no
  longer carry per-row divider lines.
- usePaginatedResource only resets to page 1 on an actual resetKey change
  (tracked via a prev-key ref), not on mount or StrictMode's double-invoked
  effect, so a URL-seeded initialPage survives the first render.
- useAuditActivity accepts initialPageSize; the page seeds it from ?size
  (validated against the allowed options).
- ActivityFeed's URL writer now syncs both ?page and ?size in one effect, so
  the two never race; size is omitted at the default to keep the URL clean.

A shared/refreshed /activity?page=4&size=25 now loads page 4 at size 25.
Page size was part of the feed's reset key, so changing it snapped back to
page 1. Drop limit from the reset key and instead refetch the current page
when the size changes, so the page number is preserved (items shift, which
is acceptable).
…redential-signup-is-an-unrate-limited-password

fix(security): rate-limit finish-credential-signup password oracle
…ists

When a fetch returns a server-clamped page (the requested page is past the
end -- e.g. page 7 at size 5 becomes out of range at size 25, or rows were
deleted), adopt meta.page and refetch so we land on the last page with real
items instead of an empty page. In-range pages are unchanged.
Non-openable rows (no chevron) let their label run into the chevron's slot,
so their right edge didn't line up with openable rows. Reserve the chevron
width with an invisible placeholder when there's no chevron.
Search:
- /api/security/audit accepts ?q and filters across actor label, action,
  resource id, the actor's current name/email, matching workflow names, and
  the request IP. Every comparison is a parameterized ILIKE (Drizzle binds
  the value) with the user's LIKE wildcards escaped, capped at 200 chars --
  no SQL injection or pattern abuse. IP is matched through the metadata JSONB
  ->> accessor so it runs in Postgres, not by scanning rows in JS.
- useAuditActivity debounces the input 500ms before it drives the query;
  a new search resets to page 1.
- Search box added to the activity page header.

Access:
- The /activity page now redirects anyone who is not an org admin/owner to
  home once auth + membership resolve, instead of rendering a gate.
The feed's URL writer now reflects the debounced search as ?q (dropped when
empty), alongside page/size, and the page seeds the search box from ?q on
load. A shared/refreshed /activity?q=... restores the search.
…write-actions-bypass-execution-guards

fix(security): gate protocol write actions through execution guards
Search only resolved workflow names among resource names, so 'discord' missed
integration events and key changes. Resolve matching integration names (and
provider type) plus org and personal API key names to id sets and match them
via resourceId, alongside the existing workflow-name match.
…arge-amount-validation

fix(security): validate Tempo MPP inner charge amount and recipient
feat(activity): move org activity to a dedicated page with search
…thorize-org-binding

fix(security): show and bind the consent org on the OAuth authorize page
…records-paid-attribution

fix(security): attribute overage records to the invoice that owns their item
@joelorzet joelorzet requested review from a team, OleksandrUA, eskp and suisuss and removed request for a team June 17, 2026 15:53
@joelorzet joelorzet merged commit de98336 into prod Jun 17, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants