diff --git a/.changeset/account-scoped-tool-policies.md b/.changeset/account-scoped-tool-policies.md new file mode 100644 index 0000000000..a258479d0a --- /dev/null +++ b/.changeset/account-scoped-tool-policies.md @@ -0,0 +1,5 @@ +--- +"@executor-js/react": patch +--- + +Tool policies set from an account section of the integration Tools tab now apply to that connection only, and each account header gets a menu to set a policy for the whole connection. Members no longer see policy controls they cannot use, and a refused policy write shows the server's reason. diff --git a/.changeset/bounded-catalog-rebuilds.md b/.changeset/bounded-catalog-rebuilds.md new file mode 100644 index 0000000000..7b0630f161 --- /dev/null +++ b/.changeset/bounded-catalog-rebuilds.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Background tool-catalog rebuilds share one executor-wide limit, set with the new `toolsSyncConcurrency` option, so reads that overlap cannot stack more rebuilds in one instance than the limit. A rebuild that is already queued is joined, not queued again, and a retry of a sync that never finished runs after the other stale catalogs. diff --git a/.changeset/connection-oauth-default.md b/.changeset/connection-oauth-default.md new file mode 100644 index 0000000000..35cfce58c2 --- /dev/null +++ b/.changeset/connection-oauth-default.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Prefer browser sign-in when a matching OAuth client is available, while preserving a user’s chosen method when clients finish loading. diff --git a/.changeset/cross-session-resume-org-write.md b/.changeset/cross-session-resume-org-write.md new file mode 100644 index 0000000000..299d43bb24 --- /dev/null +++ b/.changeset/cross-session-resume-org-write.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +An admin who resumes a paused execution from a different MCP session (for example after the client reconnects) keeps workspace-write access. The forwarded resume now carries the requester's access to the session that owns the execution, so a pending `addServer`, `addSpec`, or similar write no longer fails with `org_write_denied`. diff --git a/.changeset/fair-admin-integrations.md b/.changeset/fair-admin-integrations.md new file mode 100644 index 0000000000..a8e83972b8 --- /dev/null +++ b/.changeset/fair-admin-integrations.md @@ -0,0 +1,5 @@ +--- +"@executor-js/react": patch +--- + +Show restricted integration actions as disabled controls with an admin explanation. Members can browse the catalog and add personal connections to existing integrations. diff --git a/.changeset/oauth-discovered-scope-budget.md b/.changeset/oauth-discovered-scope-budget.md new file mode 100644 index 0000000000..c71d10c41e --- /dev/null +++ b/.changeset/oauth-discovered-scope-budget.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Request every scope a resource advertises during OAuth scope discovery, bounded by an 8 KiB scope-string budget instead of a 100-scope count. Resources with many fine-grained scopes previously received a token missing the ones it needed. Health checks without a probe no longer replace a tool-sync failure verdict with "healthy". diff --git a/.changeset/organization-settings-verification.md b/.changeset/organization-settings-verification.md new file mode 100644 index 0000000000..bae2246529 --- /dev/null +++ b/.changeset/organization-settings-verification.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +Require authenticator verification for organization settings while preserving shared workspace reads and API-key access. diff --git a/.changeset/quiet-connection-setup.md b/.changeset/quiet-connection-setup.md new file mode 100644 index 0000000000..9d75373928 --- /dev/null +++ b/.changeset/quiet-connection-setup.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Accept Slack bot and user OAuth token envelopes during sign-in and token refresh. diff --git a/.changeset/toolkit-list-scope.md b/.changeset/toolkit-list-scope.md new file mode 100644 index 0000000000..2af6981f88 --- /dev/null +++ b/.changeset/toolkit-list-scope.md @@ -0,0 +1,6 @@ +--- +"@executor-js/sdk": patch +"@executor-js/plugin-toolkits": patch +--- + +Toolkit sessions no longer walk the whole workspace catalog on connect, search, or describe: the toolkit's access patterns narrow the tool rows core reads. Tools reads no longer wait on re-listing catalogs that are only older than the freshness TTL; those rebuild in the background while the read answers from the persisted rows. Stale-marked and config-revised catalogs still gate the read within the grace budget. diff --git a/.github/scripts/check-database-capacity.py b/.github/scripts/check-database-capacity.py new file mode 100644 index 0000000000..08ea5b6fef --- /dev/null +++ b/.github/scripts/check-database-capacity.py @@ -0,0 +1,59 @@ +"""Read aggregate connection capacity with libpq; never print credentials or SQL data.""" + +import json +import os +import subprocess +import sys +from urllib.parse import unquote, urlparse + + +def main() -> int: + try: + url = urlparse(os.environ["DATABASE_URL"]) + if url.scheme not in ("postgres", "postgresql") or not url.hostname: + raise ValueError("Invalid database URL") + if url.hostname.endswith(".psdb.cloud") and url.port not in (None, 5432): + raise ValueError("Capacity checks require the direct endpoint") + env = { + **os.environ, + "PGHOST": url.hostname, + "PGPORT": str(url.port or 5432), + "PGUSER": unquote(url.username or ""), + "PGPASSWORD": unquote(url.password or ""), + "PGDATABASE": unquote(url.path.removeprefix("/")), + "PGSSLMODE": "require", + "PGCONNECT_TIMEOUT": "10", + "PGAPPNAME": "database-capacity-check", + "PGOPTIONS": "-c default_transaction_read_only=on -c statement_timeout=10000", + } + result = subprocess.run( + ["psql", "-X", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-c", """ + SELECT json_build_object( + 'limit', current_setting('max_connections')::int, + 'reserved', current_setting('superuser_reserved_connections')::int + + current_setting('reserved_connections')::int, + 'used', count(*)::int + ) FROM pg_stat_activity WHERE backend_type = 'client backend' + """], + env=env, + capture_output=True, + text=True, + timeout=25, + check=True, + ) + capacity = json.loads(result.stdout) + if any(type(capacity[key]) is not int for key in ("limit", "reserved", "used")): + raise ValueError("Invalid capacity response") + free = capacity["limit"] - capacity["reserved"] - capacity["used"] + print(json.dumps({**capacity, "ordinary_free": free, "minimum_free": 10})) + if free < 10: + print("::error::Database connection headroom is below 10 slots. Inspect direct clients and the PgBouncer budget.") + return 1 + return 0 + except (KeyError, ValueError, OSError, subprocess.SubprocessError): + print("::error::Database capacity check failed. Check direct endpoint access and provider health.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/database-capacity.yml b/.github/workflows/database-capacity.yml new file mode 100644 index 0000000000..d0610eff43 --- /dev/null +++ b/.github/workflows/database-capacity.yml @@ -0,0 +1,27 @@ +name: Database capacity + +on: + schedule: + - cron: "2-57/5 * * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: database-capacity + cancel-in-progress: false + +jobs: + check: + runs-on: ubuntu-24.04 + timeout-minutes: 2 + environment: production + steps: + - uses: actions/checkout@v4 + # psql is supplied by the Ubuntu runner image. A failed check uses the + # repository's Actions failure notifications; no customer data is logged. + - name: Check ordinary connection headroom + run: python3 .github/scripts/check-database-capacity.py + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index e42832181c..4c9a998383 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -206,6 +206,37 @@ jobs: run: bunx --bun electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish never --config electron-builder.config.ts working-directory: apps/desktop + # electron-updater installs from the zip, not the DMG, and Squirrel.Mac + # rejects it unless the extracted app passes codesign. 1.6.9 shipped a + # zip whose framework symlinks (Versions/Current -> A) had been expanded + # into copies by a 7-Zip upgrade inside electron-builder; the DMG was + # fine, every auto-update silently failed. Extract the zip the way + # Squirrel does and verify it before anything is uploaded. + - name: Verify mac update zip + if: matrix.platform == 'mac' + shell: bash + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + run: | + set -euo pipefail + zip="apps/desktop/dist/executor-desktop-mac-${{ matrix.arch }}.zip" + links=$(unzip -Z "$zip" | grep -c '^l' || true) + echo "symlink entries in $zip: $links" + if [ "$links" -eq 0 ]; then + echo "::error::$zip has no symlink entries; framework bundles were flattened and Squirrel.Mac will reject the update" + exit 1 + fi + # Unsigned builds (forks, no CSC_LINK) cannot pass codesign; the + # symlink check above still catches the flattening on its own. + if [ -z "${CSC_LINK:-}" ]; then + echo "no signing certificate configured; skipping codesign verification" + exit 0 + fi + tmp=$(mktemp -d) + ditto -x -k "$zip" "$tmp" + codesign --verify --deep --strict --verbose=1 "$tmp/Executor.app" + rm -rf "$tmp" + # The two mac legs each emit a latest-mac.yml listing only their own # arch. Rename per-arch here; the release job merges them back into the # single latest-mac.yml electron-updater clients fetch. Without this, diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 1d6806e6aa..7d64d39efb 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,17 @@ # executor +## 1.6.10 + +### Patch Changes + +- [#2044](https://github.com/UsefulSoftwareCo/executor/pull/2044) [`004024b`](https://github.com/UsefulSoftwareCo/executor/commit/004024b453e9ba07317d2893f050a0d6dae6a67b) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` to the self-host. Better Auth 1.6.17 and later enforce sign-in rate limits strictly in production, and with no trusted proxy header every caller shares one bucket of three sign-ins per ten seconds. The Docker release gate signs in from many test files at once and tripped it. The flag is off by default; the e2e harness sets it for the image it tests. + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/runtime-quickjs@1.6.10 + - @executor-js/local@1.6.10 + - @executor-js/api@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/apps/cli/package.json b/apps/cli/package.json index 8f96399565..f298ce7c4c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "1.6.9", + "version": "1.6.10", "private": true, "bin": { "executor": "./bin/executor.ts" diff --git a/apps/cloud/CHANGELOG.md b/apps/cloud/CHANGELOG.md index 407785932d..691c2e786b 100644 --- a/apps/cloud/CHANGELOG.md +++ b/apps/cloud/CHANGELOG.md @@ -1,5 +1,26 @@ # @executor-js/cloud +## 1.4.71 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/runtime-quickjs@1.6.10 + - @executor-js/execution@1.6.10 + - @executor-js/plugin-graphql@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/vite-plugin@0.0.70 + - @executor-js/cloudflare@0.0.52 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.21 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-toolkits@1.5.45 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.73 + ## 1.4.70 ### Patch Changes diff --git a/apps/cloud/docs/database-connections.md b/apps/cloud/docs/database-connections.md new file mode 100644 index 0000000000..e0304c89e6 --- /dev/null +++ b/apps/cloud/docs/database-connections.md @@ -0,0 +1,46 @@ +# Production database connections + +Application traffic uses Hyperdrive, then PlanetScale's local transaction-mode +PgBouncer on port 6432. Deployment scripts use the direct endpoint on port 5432. +Code migrations hold session advisory locks, so they must bypass transaction pooling. + +The connection budget is: + +| Setting | Value | +| ----------------------------------------- | --------------- | +| PostgreSQL max_connections | 50 | +| PostgreSQL superuser_reserved_connections | 3 | +| Local PgBouncer processes | 1 | +| PgBouncer default_pool_size | 20 | +| PgBouncer max_db_connections | 20 | +| PgBouncer max_client_conn | 400 | +| PgBouncer max_prepared_statements | 200 | +| Hyperdrive origin connection limit | 20 (soft limit) | + +Hyperdrive's origin limit is advisory. PgBouncer's database limit enforces the +backend budget across users of one database. The cap is per PgBouncer process: +adding processes, databases, direct clients, or other poolers requires a new +aggregate budget. Keep capacity for provider sessions, deploys and administration. +The 20-connection application budget leaves 27 ordinary slots for those clients +after the three superuser-reserved slots. This is a concurrency ceiling, not a +target for active queries; check CPU, queue waits and latency before raising it. +Prepared statements require protocol-level support to remain enabled in PgBouncer. + +The migration and membership-readiness scripts retry only the initial `SELECT 1` +when PostgreSQL returns SQLSTATE `53300`. They make at most seven attempts, with +ten seconds between attempts and a ten-second connection timeout. They never +retry migration bodies or readiness mutations. Other errors fail immediately. + +The Database capacity workflow checks direct access and aggregate connection +headroom every five minutes. It fails when fewer than ten ordinary slots remain. +Counts include the monitor and conservatively count privileged client sessions +against ordinary capacity. GitHub schedule delays and notification preferences +apply; this is not a real-time paging service. Check PlanetScale CPU and PgBouncer +waiting clients alongside Cloudflare query errors and latency during load spikes. + +For a routing change, first account for overlapping old and new pools. Verify +the active PostgreSQL limit and applied pool settings before changing Hyperdrive. +Afterward, check an authenticated application page, direct database access, +backend counts, and provider errors. Roll back by restoring the prior Hyperdrive +origin port only while there is capacity for both pools. Do not kill idle sessions +as routine maintenance: clients can reconnect and consume the slots again. diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 3c1dde605f..6bf5a8c91a 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloud", - "version": "1.4.70", + "version": "1.4.71", "private": true, "type": "module", "scripts": { diff --git a/apps/cloud/scripts/database-connection.ts b/apps/cloud/scripts/database-connection.ts new file mode 100644 index 0000000000..d3f725b842 --- /dev/null +++ b/apps/cloud/scripts/database-connection.ts @@ -0,0 +1,56 @@ +/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: deployment CLI connection acquisition */ + +import { setTimeout } from "node:timers/promises"; + +const MAX_ATTEMPTS = 7; +const RETRY_DELAY_MS = 10_000; + +/** + * Validate the deploy transport without logging credentials. PlanetScale schema + * migrations use the direct endpoint because code migrations hold session locks. + */ +export const directDatabaseUrl = (value: string): string => { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("DATABASE_URL must be a valid PostgreSQL URL"); + } + if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") { + throw new Error("DATABASE_URL must use the postgres or postgresql protocol"); + } + if (url.hostname.endsWith(".psdb.cloud") && url.port !== "" && url.port !== "5432") { + throw new Error("PlanetScale deploy scripts require the direct endpoint on port 5432"); + } + return value; +}; + +/** + * Open the CLI's single connection before starting work. Retry only PostgreSQL + * admission failures (53300), at most six times with ten seconds between tries. + * The caller must set connect_timeout and close the client on every exit. + * Migration and readiness mutations remain outside this retry boundary. + */ +export const waitForDatabaseConnection = async ( + sql: { readonly unsafe: (query: string) => PromiseLike }, + options: { + readonly log: (message: string) => void; + readonly sleep?: (milliseconds: number) => Promise; + }, +): Promise => { + const sleep = options.sleep ?? ((milliseconds: number) => setTimeout(milliseconds)); + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + try { + await sql.unsafe("SELECT 1"); + return; + } catch (cause) { + const isCapacityError = + typeof cause === "object" && cause !== null && "code" in cause && cause.code === "53300"; + if (!isCapacityError || attempt === MAX_ATTEMPTS) throw cause; + options.log( + `Database connection capacity is full (53300). Retrying connection ${attempt}/${MAX_ATTEMPTS - 1} in 10s; no work has started.`, + ); + await sleep(RETRY_DELAY_MS); + } + } +}; diff --git a/apps/cloud/scripts/ensure-workos-mirror-ready.ts b/apps/cloud/scripts/ensure-workos-mirror-ready.ts index d10b826d3e..30794097e0 100644 --- a/apps/cloud/scripts/ensure-workos-mirror-ready.ts +++ b/apps/cloud/scripts/ensure-workos-mirror-ready.ts @@ -36,6 +36,7 @@ import { fileURLToPath } from "node:url"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; +import { directDatabaseUrl, waitForDatabaseConnection } from "./database-connection"; import { MirrorReadinessState, @@ -56,9 +57,10 @@ if (!connectionString) { const usesLocalDatabase = connectionString.includes("127.0.0.1") || connectionString.includes("localhost"); -const sql = postgres(connectionString, { +const sql = postgres(directDatabaseUrl(connectionString), { max: 1, prepare: false, + connect_timeout: 10, ...(usesLocalDatabase ? {} : { ssl: "require" as const }), }); const db = drizzle(sql); @@ -84,6 +86,7 @@ const runScript = (what: string, script: string) => { }; try { + await waitForDatabaseConnection(sql, { log }); let state = await readiness(); log(describeMirrorReadiness(state)); diff --git a/apps/cloud/scripts/migrate.ts b/apps/cloud/scripts/migrate.ts index 9466a049b5..f610f3df9c 100644 --- a/apps/cloud/scripts/migrate.ts +++ b/apps/cloud/scripts/migrate.ts @@ -8,6 +8,7 @@ import { migrate as migrateDrizzle } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; import { cloudCodeMigrations, runCodeMigrations } from "./code-migrations/index"; +import { directDatabaseUrl, waitForDatabaseConnection } from "./database-connection"; const __dirname = dirname(fileURLToPath(import.meta.url)); const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); @@ -41,13 +42,15 @@ if (!connectionString) { const usesLocalDatabase = connectionString.includes("127.0.0.1") || connectionString.includes("localhost"); -const sql = postgres(connectionString, { +const sql = postgres(directDatabaseUrl(connectionString), { max: 1, prepare: false, + connect_timeout: 10, ...(usesLocalDatabase ? {} : { ssl: "require" as const }), }); try { + await waitForDatabaseConnection(sql, { log: console.log }); if (!codeOnly) { if (dryRun) { console.log("[schema-migrate] dry run: Drizzle SQL migrations are not applied"); diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts index d9aaf70d27..99a609877a 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -1,5 +1,7 @@ +import { env } from "cloudflare:workers"; +import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "../auth/admin-mfa-proof"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; -import { Effect, Layer } from "effect"; +import { Clock, Effect, Layer } from "effect"; import { AccountProvider, @@ -75,6 +77,16 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ // session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie). const session: Session | null = resolved ? sessionFromSealed(resolved, "") : null; + const proof = resolved + ? yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId: resolved.userId, sessionId: resolved.sessionId }, + "verified", + request.cookies[ADMIN_MFA_COOKIE], + yield* Clock.currentTimeMillis, + ) + : null; + // Built inside the request body so the WorkOS account service closes // over the per-request `UserStoreService` (postgres socket) supplied by // the combined request-scoped layer. `local` keeps that promise: the @@ -84,7 +96,7 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ AccountProvider.asEffect(), workosAccountProvider.pipe( Layer.provide(ApiKeyService.WorkOS), - Layer.provide(Layer.succeed(AccountCaller)({ session })), + Layer.provide(Layer.succeed(AccountCaller)({ session, adminVerified: proof !== null })), ), { local: true }, ); diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index 48db9c2df3..a43456e1ed 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -196,7 +196,7 @@ const providerWith = (accountId: string) => { stubDirectory, stubApiKeys, stubAutumn, - Layer.succeed(AccountCaller)({ session: session(accountId) }), + Layer.succeed(AccountCaller)({ session: session(accountId), adminVerified: false }), ), ), ), diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts index 94f5aed511..50fedfa23c 100644 --- a/apps/cloud/src/account/workos-account-service.ts +++ b/apps/cloud/src/account/workos-account-service.ts @@ -31,7 +31,7 @@ import { // the same `WorkOSClient.authenticateSealedSession` the rest of cloud uses. export class AccountCaller extends Context.Service< AccountCaller, - { readonly session: Session | null } + { readonly session: Session | null; readonly adminVerified?: boolean } >()("@executor-js/cloud/AccountCaller") {} // --------------------------------------------------------------------------- @@ -146,6 +146,16 @@ export const workosAccountProvider: Layer.Layer< const requireAdmin = (org: { readonly memberRole: "admin" | "member" }) => org.memberRole === "admin" ? Effect.void : Effect.fail(new AccountForbidden()); + // Settings mutations require MFA; shared member reads and key management do not. + const requireVerifiedSettings = (org: { readonly memberRole: "admin" | "member" }) => + Effect.gen(function* () { + yield* requireAdmin(org); + if (caller.adminVerified !== true) + return yield* new AccountForbidden({ + message: "Verify your identity to change organization settings.", + }); + }); + // Ownership check so an admin can't mutate a membership id from another // org: the id must name a row the mirror holds for THIS org (any status — // revoking a pending invite is a delete too). One point read on the @@ -390,7 +400,7 @@ export const workosAccountProvider: Layer.Layer< inviteMember: (headers, body) => Effect.gen(function* () { const { org } = yield* requireOrganization(headers); - yield* requireAdmin(org); + yield* requireVerifiedSettings(org); yield* reserveMemberSlot(org.id); const invitation = yield* workos .sendInvitation({ @@ -422,7 +432,7 @@ export const workosAccountProvider: Layer.Layer< removeMember: (headers, membershipId) => Effect.gen(function* () { const { org } = yield* requireOrganization(headers); - yield* requireAdmin(org); + yield* requireVerifiedSettings(org); const membership = yield* assertMembershipInOrg(org.id, membershipId); yield* workos .deleteOrgMembership(membershipId) @@ -453,7 +463,7 @@ export const workosAccountProvider: Layer.Layer< updateMemberRole: (headers, membershipId, roleSlug) => Effect.gen(function* () { const { org } = yield* requireOrganization(headers); - yield* requireAdmin(org); + yield* requireVerifiedSettings(org); yield* assertMembershipInOrg(org.id, membershipId); const updated = yield* workos .updateOrgMembershipRole(membershipId, roleSlug) @@ -467,7 +477,7 @@ export const workosAccountProvider: Layer.Layer< updateOrgName: (headers, name) => Effect.gen(function* () { const { org } = yield* requireOrganization(headers); - yield* requireAdmin(org); + yield* requireVerifiedSettings(org); const updated = yield* workos .updateOrganization(org.id, name) .pipe(Effect.catchTag("WorkOSError", toAccountError)); diff --git a/apps/cloud/src/api/router.ts b/apps/cloud/src/api/router.ts index 8c80825ef2..5851158f26 100644 --- a/apps/cloud/src/api/router.ts +++ b/apps/cloud/src/api/router.ts @@ -11,6 +11,7 @@ import { UserStoreService } from "../auth/context"; import { WorkOsMirror } from "../auth/workos-mirror"; import { DbService } from "../db/db"; import { makeAccountApiLive } from "../account/account-api"; +import { AdminMfaRoutes } from "../auth/admin-mfa-routes"; import { AutumnRoutesLive } from "../extensions/billing/route"; import { CloudDocsLive } from "../extensions/docs"; @@ -41,6 +42,7 @@ export const makeApiLive = ( Layer.provide(requestScopedMiddleware(requestScopedLive).layer), ); return Layer.mergeAll( + AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(requestScopedLive).layer)), makeNonProtectedApiLive(requestScopedLive), makeOrgApiLive(requestScopedLive), makeAccountApiLive(requestScopedLive), diff --git a/apps/cloud/src/auth/admin-mfa-proof.test.ts b/apps/cloud/src/auth/admin-mfa-proof.test.ts new file mode 100644 index 0000000000..96fa7a8f03 --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-proof.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { SignJWT } from "jose"; +import { readAdminMfaProof, signAdminMfaProof } from "./admin-mfa-proof"; + +const secret = "a-test-only-cookie-password-of-32-characters"; +const identity = { userId: "user_test", sessionId: "session_test" }; +const now = 1_800_000_000_000; +const proof = { + mode: "challenge" as const, + factorId: "factor_test", + challengeId: "challenge_test", + exp: now / 1000 + 900, +}; +const signed = signAdminMfaProof(secret, identity, "verified", proof, now); + +describe("admin verification cookie", () => { + it.effect("accepts a valid proof for the same user and session", () => + Effect.gen(function* () { + const token = yield* signed; + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toEqual(proof); + }), + ); + + it.effect("refuses missing, modified, and unsigned cookies", () => + Effect.gen(function* () { + const token = yield* signed; + const parts = token.split("."); + const unsigned = `${btoa('{"alg":"none"}')}.${parts[1]}.`; + for (const value of [ + undefined, + "", + "bad.cookie", + `${token.slice(0, 50)}x${token.slice(51)}`, + unsigned, + ]) { + expect(yield* readAdminMfaProof(secret, identity, "verified", value, now)).toBeNull(); + } + }), + ); + + it.effect("refuses another session, another user, and another signing key", () => + Effect.gen(function* () { + const token = yield* signed; + for (const other of [ + { ...identity, userId: "other" }, + { ...identity, sessionId: "other" }, + ]) { + expect(yield* readAdminMfaProof(secret, other, "verified", token, now)).toBeNull(); + } + expect( + yield* readAdminMfaProof(`${secret}-rotated`, identity, "verified", token, now), + ).toBeNull(); + }), + ); + + it.effect("cannot promote an unfinished challenge to verified access", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "challenge", + { ...proof, mode: "enroll", exp: now / 1000 + 300 }, + now, + ); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "challenge", token, now + 299_000), + ).not.toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "challenge", token, now + 300_000), + ).toBeNull(); + }), + ); + + it.effect("honors the signed expiration and refuses a future-issued cookie", () => + Effect.gen(function* () { + const token = yield* signed; + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 899_000), + ).not.toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 900_000), + ).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now - 10_000), + ).toBeNull(); + }), + ); + + it.effect("keeps the verified session unlocked beyond the former fifteen-minute window", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "verified", + { + ...proof, + exp: now / 1000 + 7 * 86400, + }, + now, + ); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 3600_000), + ).not.toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 7 * 86400_000), + ).toBeNull(); + }), + ); + + it.effect("caps token age even when the supplied expiration is longer", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "verified", + { ...proof, exp: now / 1000 + 8 * 86400 }, + now, + ); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 901_000), + ).toBeNull(); + }), + ); + + it.effect("rejects a signed cookie with missing issued-at or another algorithm", () => + Effect.gen(function* () { + for (const algorithm of ["HS256", "HS384"]) { + const jwt = new SignJWT({ ...proof }) + .setProtectedHeader({ alg: algorithm }) + .setIssuer("executor:admin-mfa:verified") + .setSubject(identity.userId) + .setAudience(identity.sessionId); + // HS256 lacks iat; HS384 is otherwise valid but outside the allowlist. + if (algorithm === "HS384") jwt.setIssuedAt(now / 1000); + const token = yield* Effect.promise(() => jwt.sign(new TextEncoder().encode(secret))); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + } + }), + ); +}); diff --git a/apps/cloud/src/auth/admin-mfa-proof.ts b/apps/cloud/src/auth/admin-mfa-proof.ts new file mode 100644 index 0000000000..33f06f8d24 --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-proof.ts @@ -0,0 +1,89 @@ +import { Data, Effect, Option, Schema } from "effect"; +import { SignJWT, jwtVerify } from "jose"; + +/** HttpOnly cookies used only for the administrative verification flow. */ +export const ADMIN_MFA_COOKIE = "__Host-executor-admin-mfa"; +/** The pending challenge is bound to the same user and WorkOS session. */ +export const ADMIN_MFA_CHALLENGE_COOKIE = "__Host-executor-admin-challenge"; +/** Verification is session-bound, with a seven-day maximum matching the login cookie. */ +export const ADMIN_MFA_TTL_SECONDS = 7 * 24 * 60 * 60; + +/** A verified WorkOS session, supplied by the authentication adapter. */ +export interface AdminMfaIdentity { + readonly userId: string; + readonly sessionId: string; +} + +const Proof = Schema.Struct({ + factorId: Schema.String, + challengeId: Schema.String, + mode: Schema.Literals(["enroll", "challenge"]), + exp: Schema.Number, +}); +const decodeProof = Schema.decodeUnknownOption(Proof); + +/** Signing failures are server failures; invalid input cookies are simply refused. */ +export class AdminMfaProofError extends Data.TaggedError("AdminMfaProofError")<{ + readonly cause: unknown; +}> {} + +type Purpose = "challenge" | "verified"; +const issuer = (purpose: Purpose) => `executor:admin-mfa:${purpose}`; +const key = (secret: string) => new TextEncoder().encode(secret); + +/** Sign a purpose-specific, session-bound proof with an explicit expiration. */ +export const signAdminMfaProof = ( + secret: string, + identity: AdminMfaIdentity, + purpose: Purpose, + proof: typeof Proof.Type, + now: number, +) => + Effect.tryPromise({ + try: () => + new SignJWT({ factorId: proof.factorId, challengeId: proof.challengeId, mode: proof.mode }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer(issuer(purpose)) + .setSubject(identity.userId) + .setAudience(identity.sessionId) + .setIssuedAt(Math.floor(now / 1000)) + .setExpirationTime(proof.exp) + .sign(key(secret)), + catch: (cause) => new AdminMfaProofError({ cause }), + }); + +/** Reject expired, tampered, cross-user, cross-session, and wrong-purpose proofs. */ +export const readAdminMfaProof = ( + secret: string, + identity: AdminMfaIdentity, + purpose: Purpose, + token: string | undefined, + now: number, +) => { + if (!token) return Effect.succeed(null); + return Effect.tryPromise({ + try: () => + jwtVerify(token, key(secret), { + algorithms: ["HS256"], + issuer: issuer(purpose), + subject: identity.userId, + audience: identity.sessionId, + requiredClaims: ["exp", "iat", "sub", "aud"], + maxTokenAge: purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS, + currentDate: new Date(now), + }), + catch: (cause) => new AdminMfaProofError({ cause }), + }).pipe( + Effect.map(({ payload }) => { + const maxAge = purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS; + if ( + typeof payload.iat !== "number" || + typeof payload.exp !== "number" || + payload.exp > payload.iat + maxAge + ) + return null; + return Option.getOrNull(decodeProof(payload)); + }), + Effect.catchTag("AdminMfaProofError", () => Effect.succeed(null)), + ); +}; diff --git a/apps/cloud/src/auth/admin-mfa-routes.ts b/apps/cloud/src/auth/admin-mfa-routes.ts new file mode 100644 index 0000000000..107eb2edc3 --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-routes.ts @@ -0,0 +1,244 @@ +import { env } from "cloudflare:workers"; +import { Clock, Data, Duration, Effect, Layer, Option, Schema, Stream } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { WorkOSClient } from "./workos"; +import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "./organization"; +import { + ADMIN_MFA_COOKIE, + ADMIN_MFA_CHALLENGE_COOKIE, + ADMIN_MFA_TTL_SECONDS, + readAdminMfaProof, + signAdminMfaProof, +} from "./admin-mfa-proof"; + +const codeBody = Schema.Struct({ code: Schema.String.check(Schema.isPattern(/^\d{6}$/)) }); +const parseCodeBody = Schema.decodeUnknownOption(Schema.fromJsonString(codeBody)); +class RateLimitError extends Data.TaggedError("AdminMfaRateLimitError")<{ + readonly cause: unknown; +}> {} +class CodeBodyTooLarge extends Data.TaggedError("CodeBodyTooLarge") {} +const cookieOptions = { + path: "/", + httpOnly: true, + secure: true, + sameSite: "strict" as const, +}; +const json = (body: unknown, status = 200) => + HttpServerResponse.jsonUnsafe(body, { status, headers: { "cache-control": "no-store" } }); + +const handler = (action: "status" | "start" | "verify" | "cancel" | "lock") => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const webRequest = yield* HttpServerRequest.toWeb(request); + if (action !== "status" && request.headers.origin !== new URL(webRequest.url).origin) { + return json({ message: "This request must come from Executor." }, 403); + } + const workos = yield* WorkOSClient; + const session = yield* workos.authenticateRequest(webRequest); + if (!session) return json({ message: "Sign in to continue." }, 401); + const response = yield* Effect.gen(function* () { + const selector = request.headers[ORG_SELECTOR_HEADER]; + const org = selector ? yield* authorizeOrganizationSelector(session.userId, selector) : null; + if (!org) return json({ message: "Select an organization to continue." }, 403); + if (action === "status") { + const proof = yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId: session.userId, sessionId: session.sessionId }, + "verified", + request.cookies[ADMIN_MFA_COOKIE], + yield* Clock.currentTimeMillis, + ); + return json( + proof !== null ? { state: "verified", expiresAt: proof.exp } : { state: "required" }, + ); + } + + if (action === "lock") { + return json({ canceled: true }).pipe( + HttpServerResponse.setCookieUnsafe(ADMIN_MFA_COOKIE, "", { + ...cookieOptions, + maxAge: Duration.seconds(0), + }), + HttpServerResponse.setCookieUnsafe(ADMIN_MFA_CHALLENGE_COOKIE, "", { + ...cookieOptions, + maxAge: Duration.seconds(0), + }), + ); + } + if (action === "cancel") { + return HttpServerResponse.setCookieUnsafe( + json({ canceled: true }), + ADMIN_MFA_CHALLENGE_COOKIE, + "", + { + ...cookieOptions, + maxAge: Duration.seconds(0), + }, + ); + } + + // Applies across new challenges too, so starting over cannot reset the attempt budget. + const rateLimit = env.ADMIN_MFA_RATE_LIMITER; + if (!rateLimit) return json({ message: "Verification is temporarily unavailable." }, 503); + const allowed = yield* Effect.tryPromise({ + try: () => rateLimit.limit({ key: session.userId }), + catch: (cause) => new RateLimitError({ cause }), + }); + if (!allowed.success) return json({ message: "Wait a minute, then try again." }, 429); + + const now = yield* Clock.currentTimeMillis; + const identity = { userId: session.userId, sessionId: session.sessionId }; + const factors = yield* workos.listMfaFactors(session.userId); + if (action === "start") { + const existing = factors[0]; + const started = existing + ? { + kind: "challenge" as const, + factor: existing, + challenge: yield* workos.challengeMfa(existing.id), + } + : yield* workos.enrollMfa(session.userId, session.email).pipe( + Effect.map((result) => ({ + kind: "enroll" as const, + factor: result.authenticationFactor, + challenge: result.authenticationChallenge, + })), + ); + const token = yield* signAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + identity, + "challenge", + { + mode: started.kind, + factorId: started.factor.id, + challengeId: started.challenge.id, + exp: Math.floor(now / 1000) + 5 * 60, + }, + now, + ); + const response = + started.kind === "enroll" + ? json({ + kind: "enroll", + secret: started.factor.totp.secret, + qrCode: started.factor.totp.qrCode, + }) + : json({ kind: "challenge" }); + return HttpServerResponse.setCookieUnsafe(response, ADMIN_MFA_CHALLENGE_COOKIE, token, { + ...cookieOptions, + maxAge: Duration.minutes(5), + }); + } + + const pending = yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + identity, + "challenge", + request.cookies[ADMIN_MFA_CHALLENGE_COOKIE], + now, + ); + // AuthKit lists only verified factors. An enrollment may proceed only while + // none is active; a stale setup must not add a factor after another setup won. + if ( + !pending || + (pending.mode === "enroll" + ? factors.length !== 0 + : !factors.some( + (factor) => factor.id === pending.factorId && factor.userId === session.userId, + )) + ) { + return json({ message: "Start verification again." }, 400); + } + const text = yield* request.stream.pipe( + Stream.runFoldEffect( + () => new Uint8Array(0), + (body, chunk) => { + if (body.length + chunk.length > 256) return Effect.fail(new CodeBodyTooLarge()); + const next = new Uint8Array(body.length + chunk.length); + next.set(body); + next.set(chunk, body.length); + return Effect.succeed(next); + }, + ), + Effect.map((body) => new TextDecoder().decode(body)), + Effect.catch(() => Effect.succeed("")), + ); + const body = Option.getOrNull(parseCodeBody(text)); + if (!body) return json({ message: "Enter the six-digit code." }, 400); + const result = yield* workos + .verifyMfa(pending.challengeId, body.code) + .pipe( + Effect.catchTag("WorkOSError", (error) => + error.status === 400 || error.status === 422 + ? Effect.succeed(null) + : Effect.fail(error), + ), + ); + if ( + !result || + !result.valid || + result.challenge.authenticationFactorId !== pending.factorId + ) { + return json( + { message: "That code did not work. Try the current code from your authenticator." }, + 400, + ); + } + const active = yield* workos.listMfaFactors(session.userId); + if ( + !active.some((factor) => factor.id === pending.factorId && factor.userId === session.userId) + ) { + return json({ message: "Start verification again." }, 400); + } + const token = yield* signAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + identity, + "verified", + { + ...pending, + exp: Math.floor(now / 1000) + ADMIN_MFA_TTL_SECONDS, + }, + now, + ); + return json({ verified: true }).pipe( + HttpServerResponse.setCookieUnsafe(ADMIN_MFA_COOKIE, token, { + ...cookieOptions, + sameSite: "strict", + }), + HttpServerResponse.setCookieUnsafe(ADMIN_MFA_CHALLENGE_COOKIE, "", { + ...cookieOptions, + maxAge: Duration.seconds(0), + }), + ); + }).pipe( + Effect.catch(() => + Effect.succeed( + json({ message: "Verification is temporarily unavailable. Try again." }, 503), + ), + ), + ); + // Refresh tokens rotate once. Persist the new sealed session even when + // verification is refused, so the next request can still authenticate. + return session.refreshedSession + ? HttpServerResponse.setCookieUnsafe(response, "wos-session", session.refreshedSession, { + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: Duration.days(7), + }) + : response; + }).pipe( + Effect.catch(() => + Effect.succeed(json({ message: "Verification is temporarily unavailable. Try again." }, 503)), + ), + ); + +/** Session-bound TOTP verification routes. Mount with the normal request-scoped directory. */ +export const AdminMfaRoutes = Layer.mergeAll( + HttpRouter.add("GET", "/api/auth/admin-mfa", handler("status")), + HttpRouter.add("POST", "/api/auth/admin-mfa/start", handler("start")), + HttpRouter.add("POST", "/api/auth/admin-mfa/verify", handler("verify")), + HttpRouter.add("POST", "/api/auth/admin-mfa/cancel", handler("cancel")), + HttpRouter.add("POST", "/api/auth/admin-mfa/lock", handler("lock")), +); diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 6453a0547f..b378771af7 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -1,3 +1,4 @@ +import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "./admin-mfa-proof"; import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { Clock, Duration, Effect, Predicate } from "effect"; @@ -556,6 +557,18 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // earlier attempt failed after the mark can send it again and finish. const session = yield* selectedOrganization({ deleted: "allow" }); const organizationId = session.organizationId; + const request = yield* HttpServerRequest.HttpServerRequest; + const verifiedSession = yield* workos.authenticateSealedSession(session.sealedSession); + const proof = verifiedSession + ? yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId: verifiedSession.userId, sessionId: verifiedSession.sessionId }, + "verified", + request.cookies[ADMIN_MFA_COOKIE], + yield* Clock.currentTimeMillis, + ) + : null; + if (!proof) return yield* new OrganizationDeletionForbidden(); // Admin-only. `requireSelectedOrganization` already read the caller's // mirrored membership, required it ACTIVE (a pending admin invite is diff --git a/apps/cloud/src/auth/mirror-feeders.node.test.ts b/apps/cloud/src/auth/mirror-feeders.node.test.ts index b1cb9ba1de..d166e49b83 100644 --- a/apps/cloud/src/auth/mirror-feeders.node.test.ts +++ b/apps/cloud/src/auth/mirror-feeders.node.test.ts @@ -1,3 +1,4 @@ +import { verifiedSettingsCookie } from "../../test-stubs/verified-settings"; // --------------------------------------------------------------------------- // The membership mirror's FEEDERS, end to end through the code that runs in // production, against the real PGlite Postgres every cloud unit test runs on @@ -594,6 +595,7 @@ describe("session handlers read membership from the mirror", () => { authenticateSealedSession: () => Effect.succeed({ userId, + sessionId: "test-settings-session", email: `${userId}@placeholder.test`, organizationId: null, } as never), @@ -710,11 +712,11 @@ describe("session handlers read membership from the mirror", () => { return slug; }; - const deleteOrganizationRequest = (org: string) => + const deleteOrganizationRequest = async (org: string, userId: string) => new Request("http://test.local/auth/delete-organization", { method: "POST", headers: { - cookie: "wos-session=sealed", + cookie: `wos-session=sealed; ${await verifiedSettingsCookie(userId)}`, "content-type": "application/json", [ORG_SELECTOR_HEADER]: org, }, @@ -761,7 +763,7 @@ describe("session handlers read membership from the mirror", () => { // requires an ACTIVE membership, so the invite grants no deletion right. await seedMembership(userId, org, "pending", "admin"); - const response = await sessionHandler(userId)(deleteOrganizationRequest(org)); + const response = await sessionHandler(userId)(await deleteOrganizationRequest(org, userId)); // The selector resolves no active membership, so the request fails at the // org check (NoOrganization) — the handler never reaches the WorkOS @@ -775,7 +777,7 @@ describe("session handlers read membership from the mirror", () => { const org = freshId("org"); await seedMembership(userId, org, "active", "member"); - const response = await sessionHandler(userId)(deleteOrganizationRequest(org)); + const response = await sessionHandler(userId)(await deleteOrganizationRequest(org, userId)); expect(response.status).toBe(403); expect( @@ -805,7 +807,7 @@ describe("session handlers read membership from the mirror", () => { }), }, }); - const first = await failing(deleteOrganizationRequest(org)); + const first = await failing(await deleteOrganizationRequest(org, admin)); expect(first.status, "the failed purge is surfaced, not hidden").toBe(500); expect(workosDeletes).toEqual([org]); expect(purges).toEqual(["deleteOrganizationCascade"]); @@ -825,7 +827,7 @@ describe("session handlers read membership from the mirror", () => { deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })), }, }); - const second = await retry(deleteOrganizationRequest(org)); + const second = await retry(await deleteOrganizationRequest(org, admin)); expect(second.status, "the admin's own membership still admits the retry").toBe(200); expect(await second.json()).toEqual({ success: true }); expect( @@ -879,7 +881,7 @@ describe("session handlers read membership from the mirror", () => { }, }); - const first = await handler(deleteOrganizationRequest(org)); + const first = await handler(await deleteOrganizationRequest(org, admin)); expect(first.status, "the failed billing cancel is surfaced, not hidden").toBe(500); expect(await first.json()).toMatchObject({ _tag: "OrganizationDeletionIncomplete", @@ -894,7 +896,7 @@ describe("session handlers read membership from the mirror", () => { ).toEqual([admin, member].sort()); expect(await authorized(member, org), "yet nobody is authorized: the mark stands").toBe(false); - const second = await handler(deleteOrganizationRequest(org)); + const second = await handler(await deleteOrganizationRequest(org, admin)); expect(second.status, "the admin's own membership row still admits the retry").toBe(200); expect(await second.json()).toEqual({ success: true }); expect(workosDeletes, "WorkOS is asked once billing is cancelled").toEqual([org]); @@ -920,7 +922,7 @@ describe("session handlers read membership from the mirror", () => { services: servicesWithFailingPurge(purges), autumn: deletingAutumn, workos: { deleteOrganization: () => Effect.void }, - })(deleteOrganizationRequest(org)); + })(await deleteOrganizationRequest(org, admin)); expect(first.status).toBe(500); expect(purges).toEqual(["deleteOrganizationCascade"]); @@ -933,7 +935,7 @@ describe("session handlers read membership from the mirror", () => { deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })), }, }); - const second = await retry(deleteOrganizationRequest(org)); + const second = await retry(await deleteOrganizationRequest(org, admin)); expect(second.status, "the retry is admitted from the mirror").toBe(200); expect(await second.json()).toEqual({ success: true }); expect(await readMembers(org), "and the purge ran").toEqual([]); @@ -1158,7 +1160,7 @@ describe("account service writes through to the mirror", () => { workos, stubApiKeys, options.autumn ?? stubAutumn, - Layer.succeed(AccountCaller)({ session: session(ADMIN) }), + Layer.succeed(AccountCaller)({ session: session(ADMIN), adminVerified: true }), ), ), Layer.provideMerge(stores), diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 918a7e8556..af37f69218 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -431,6 +431,17 @@ const make = Effect.gen(function* () { tryPromiseService(() => fn(workos)), ); + // MFA SDK errors can contain response details. Keep only the status before + // logging, so enrollment secrets and submitted codes cannot enter a cause. + const useMfa = (op: string, fn: (wos: WorkOS) => Promise) => + tryPromiseService(() => fn(workos)).pipe( + Effect.mapError(workosErrorFromFailure), + Effect.tapError((error) => + Effect.logWarning(`workos.${op} failed`, { status: error.status }), + ), + Effect.withSpan(`workos.${op}`), + ); + const authenticateSealedSession = (sessionData: string) => Effect.gen(function* () { if (!sessionData) return null; @@ -482,6 +493,31 @@ const make = Effect.gen(function* () { }); return { + /** List factors belonging to this user; callers cannot supply another user's factor. */ + listMfaFactors: (userId: string) => + useMfa("userManagement.listAuthFactors", (wos) => + wos.userManagement + .listAuthFactors({ userId, limit: 100 }) + .then((page) => page.autoPagination()), + ), + /** Begin AuthKit's user-bound TOTP enrollment. The secret is returned only to that user. */ + enrollMfa: (userId: string, email: string) => + useMfa("userManagement.enrollAuthFactor", (wos) => + wos.userManagement.enrollAuthFactor({ + userId, + type: "totp", + totpIssuer: "Executor", + totpUser: email, + }), + ), + /** Challenge an already resolved factor. */ + challengeMfa: (authenticationFactorId: string) => + useMfa("mfa.challengeFactor", (wos) => wos.mfa.challengeFactor({ authenticationFactorId })), + /** Verify a TOTP code with WorkOS; never log the code or factor secret. */ + verifyMfa: (authenticationChallengeId: string, code: string) => + useMfa("mfa.verifyChallenge", (wos) => + wos.mfa.verifyChallenge({ authenticationChallengeId, code }), + ), getAuthorizationUrl: (redirectUri: string, state?: string) => workos.userManagement.getAuthorizationUrl({ provider: "authkit", diff --git a/apps/cloud/src/db/deployment-connection.test.ts b/apps/cloud/src/db/deployment-connection.test.ts new file mode 100644 index 0000000000..015b88db81 --- /dev/null +++ b/apps/cloud/src/db/deployment-connection.test.ts @@ -0,0 +1,93 @@ +/* oxlint-disable executor/no-promise-reject -- boundary: simulate the Postgres.js driver's rejected promises */ + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { directDatabaseUrl, waitForDatabaseConnection } from "../../scripts/database-connection"; + +describe("deployment database connection", () => { + it.effect("waits for admission before allowing deployment work", () => + Effect.promise(async () => { + let remainingFailures = 2; + const waits: number[] = []; + const logs: string[] = []; + const queries: string[] = []; + await waitForDatabaseConnection( + { + unsafe: (query) => { + queries.push(query); + return remainingFailures-- > 0 + ? Promise.reject({ code: "53300", detail: "private connection data" }) + : Promise.resolve([]); + }, + }, + { + log: (line) => logs.push(line), + sleep: async (ms) => { + waits.push(ms); + }, + }, + ); + expect(queries).toEqual(["SELECT 1", "SELECT 1", "SELECT 1"]); + expect(waits).toEqual([10_000, 10_000]); + expect(logs).toHaveLength(2); + expect(logs.join()).not.toContain("private connection data"); + }), + ); + + it.effect("fails after the bounded admission budget", () => + Effect.promise(async () => { + const failure = { code: "53300" }; + let attempts = 0; + const waits: number[] = []; + await expect( + waitForDatabaseConnection( + { + unsafe: () => { + attempts += 1; + return Promise.reject(failure); + }, + }, + { + log: () => {}, + sleep: async (ms) => { + waits.push(ms); + }, + }, + ), + ).rejects.toBe(failure); + expect(attempts).toBe(7); + expect(waits).toEqual(Array(6).fill(10_000)); + }), + ); + + it.effect("fails immediately for authentication, transport and SQL errors", () => + Effect.promise(async () => { + for (const code of ["28P01", "CONNECT_TIMEOUT", "CONNECTION_CLOSED", "42601", "40001"]) { + const failure = { code }; + const waits: number[] = []; + await expect( + waitForDatabaseConnection( + { unsafe: () => Promise.reject(failure) }, + { + log: () => {}, + sleep: async (ms) => { + waits.push(ms); + }, + }, + ), + ).rejects.toBe(failure); + expect(waits).toEqual([]); + } + }), + ); + + it("keeps PlanetScale deployment traffic on the direct endpoint", () => { + const direct = "postgres://example:secret@region.pg.psdb.cloud:5432/database"; + expect(directDatabaseUrl(direct)).toBe(direct); + expect(directDatabaseUrl("postgres://localhost:25432/postgres")).toContain(":25432"); + expect(() => directDatabaseUrl(direct.replace(":5432", ":6432"))).toThrow("direct endpoint"); + expect(() => directDatabaseUrl("invalid-secret")).toThrow("valid PostgreSQL URL"); + expect(() => directDatabaseUrl("https://localhost/database")).toThrow("protocol"); + }); +}); diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 715991f394..017570cf1a 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -5,6 +5,12 @@ declare global { namespace Cloudflare { interface Env { + /** TOTP enrollment and verification attempts; absence refuses verification. */ + ADMIN_MFA_RATE_LIMITER?: { + readonly limit: (options: { + readonly key: string; + }) => Promise<{ readonly success: boolean }>; + }; // Observability // Worker version metadata binding (wrangler.jsonc `version_metadata`). // Optional so test workers and local setups without the binding still diff --git a/apps/cloud/src/extensions/billing/route.node.test.ts b/apps/cloud/src/extensions/billing/route.node.test.ts index 956bb83c3d..2ee329df2d 100644 --- a/apps/cloud/src/extensions/billing/route.node.test.ts +++ b/apps/cloud/src/extensions/billing/route.node.test.ts @@ -6,7 +6,11 @@ import { MemberDirectory } from "@executor-js/api/server"; import { UserStoreService } from "../../auth/context"; import { WorkOSClient, type WorkOSClientService } from "../../auth/workos"; import { WorkOsMirror, type WorkOsMirrorShape } from "../../auth/workos-mirror"; -import { resolveBillingOrganization } from "./route"; +import { + CHECKOUT_TAX_ID_PARAMS, + resolveBillingOrganization, + withCheckoutTaxIdCollection, +} from "./route"; const createdAt = new Date("2026-01-01T00:00:00.000Z"); @@ -141,3 +145,21 @@ describe("billing route org selector", () => { }), ); }); + +describe("billing checkout tax ID collection", () => { + it("asks Stripe Checkout to collect a tax ID on attach", () => { + const body = withCheckoutTaxIdCollection("/api/billing/attach", { + planId: "team", + checkoutSessionParams: { locale: "auto", tax_id_collection: { enabled: false } }, + }); + expect(body).toEqual({ + planId: "team", + checkoutSessionParams: { locale: "auto", ...CHECKOUT_TAX_ID_PARAMS }, + }); + }); + + it("leaves other billing routes unchanged", () => { + const body = { planId: "team" }; + expect(withCheckoutTaxIdCollection("/api/billing/previewAttach", body)).toBe(body); + }); +}); diff --git a/apps/cloud/src/extensions/billing/route.ts b/apps/cloud/src/extensions/billing/route.ts index 78a32323fd..e353c6c2be 100644 --- a/apps/cloud/src/extensions/billing/route.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -15,6 +15,29 @@ type BillingSession = { readonly userId: string; }; +const ATTACH_PATH = "/api/billing/attach"; + +// Stripe Checkout hides the VAT / tax ID field unless the session asks for it. +// Autumn always passes an existing Stripe customer, and Stripe then requires +// `customer_update.name = "auto"` so it can save the business name. Set on the +// server so every checkout gets it, whatever the client sends. +export const CHECKOUT_TAX_ID_PARAMS = { + tax_id_collection: { enabled: true }, + billing_address_collection: "required", + customer_update: { name: "auto", address: "auto" }, +} as const; + +export const withCheckoutTaxIdCollection = (pathname: string, body: unknown): unknown => { + if (pathname !== ATTACH_PATH || typeof body !== "object" || body === null) return body; + const { checkoutSessionParams, ...rest } = body as { + readonly checkoutSessionParams?: Record; + }; + return { + ...rest, + checkoutSessionParams: { ...checkoutSessionParams, ...CHECKOUT_TAX_ID_PARAMS }, + }; +}; + export const resolveBillingOrganization = (request: Request, session: BillingSession) => Effect.gen(function* () { // FAIL CLOSED: no header, no org. The AutumnProvider always sends the @@ -84,7 +107,7 @@ const handler = Effect.gen(function* () { request: { url: url.pathname, method: request.method, - body, + body: withCheckoutTaxIdCollection(url.pathname, body), }, customerId: org.id, customerData: { diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index f1c4389fe7..9892af061d 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -38,6 +38,7 @@ import { NonProtectedApi, } from "../auth/handlers"; import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; +import { AdminMfaRoutes } from "../auth/admin-mfa-routes"; import { SessionAuthLive } from "../auth/middleware-live"; import { runWorkOsEventsSync } from "../auth/workos-events-runner"; import { makeWorkOsWebhookRoute } from "../auth/workos-webhook"; @@ -131,6 +132,7 @@ export const makeCloudExtensionRoutes = ( }); return [ + AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(rsLive).layer)), SessionRoutes, OrgRoutes, AdminUsersRoutes, diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 701166ece7..81d9b50d4e 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -33,6 +33,7 @@ import { type BuiltMcpServer, type IncomingTraceHeaders, type McpApprovalOwner, + type McpModelResumeCaller, type McpSessionModelResumeResult, type McpSessionInit, type SessionMeta, @@ -235,7 +236,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { diff --git a/apps/cloud/src/org/auth-middleware.ts b/apps/cloud/src/org/auth-middleware.ts index 9c61236f3b..848f347889 100644 --- a/apps/cloud/src/org/auth-middleware.ts +++ b/apps/cloud/src/org/auth-middleware.ts @@ -1,4 +1,6 @@ -import { Context, Effect, Layer } from "effect"; +import { env } from "cloudflare:workers"; +import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "../auth/admin-mfa-proof"; +import { Clock, Context, Effect, Layer } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { @@ -71,6 +73,19 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ ); if (!org) return noOrganization(); + const proof = yield* readAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId: result.userId, sessionId: result.sessionId }, + "verified", + request.cookies[ADMIN_MFA_COOKIE], + yield* Clock.currentTimeMillis, + ); + if (!proof) + return HttpServerResponse.jsonUnsafe( + { _tag: "Forbidden", message: "Verify your identity to open organization settings." }, + { status: 403, headers: { "cache-control": "no-store" } }, + ); + const session = sessionFromSealed(result, cookieValue); const auth = AuthContext.of({ accountId: session.accountId, diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index 1cae2aa9a2..b5c99af906 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -1,3 +1,4 @@ +import { verifiedSettingsCookie } from "../../test-stubs/verified-settings"; import { afterAll, describe, expect, it } from "@effect/vitest"; import { Data, Effect, Layer } from "effect"; import { HttpRouter, HttpServer } from "effect/unstable/http"; @@ -239,6 +240,7 @@ const workosForCaller = (deleted: string[]) => authenticateSealedSession: () => Effect.succeed({ userId: CALLER, + sessionId: "test-settings-session", email: "caller@placeholder.test", organizationId: ORG, }), @@ -274,7 +276,10 @@ const deleteDomain = async (role: "admin" | "member") => { const response = await app.handler( new Request(`https://executor.test/org/domains/${DOMAIN}`, { method: "DELETE", - headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG }, + headers: { + cookie: `wos-session=sealed; ${await verifiedSettingsCookie(CALLER)}`, + [ORG_SELECTOR_HEADER]: ORG, + }, }), // beta.59: the handler type expects a context argument; this layer stack // needs none at runtime — pass undefined like the api.request-scope tests. diff --git a/apps/cloud/src/routes/app/org.tsx b/apps/cloud/src/routes/app/org.tsx index a9ded0b7c8..15af2e2f7e 100644 --- a/apps/cloud/src/routes/app/org.tsx +++ b/apps/cloud/src/routes/app/org.tsx @@ -1,3 +1,4 @@ +import { AdminVerification } from "../../web/components/admin-verification"; import { useState } from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { Exit } from "effect"; @@ -56,6 +57,14 @@ type DomainData = { }; function OrgPage() { + return ( + + + + ); +} + +function OrganizationSettings() { return (
{/* Shared members / roles / invite / org-name surface. */} diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index fc9c146f3a..71905e352f 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -243,19 +243,64 @@ const markStartGraphEntered = (): void => { // `servedByAppPlane` (./app-paths) decides which paths qualify — two under // `/api` are claimed by Start's middleware first and must keep their old route. -// Instantiated on the first request that needs it and memoized per isolate, -// mirroring `start.ts`'s `getApp`. The import stays dynamic so an isolate that -// only serves pages or proxies never evaluates the app graph at all. -let appPlane: ReturnType | undefined; +// Instantiated once per isolate and memoized as a promise, mirroring +// `start.ts`'s `getApp`. The import stays dynamic so the Worker's static +// startup closure does not include the app graph; the promise memo means a +// pre-warm and a real request racing on a fresh isolate share one import. +type AppPlane = ReturnType; +let appPlanePromise: Promise | undefined; let appGraphEntered = false; -const getAppPlane = async (): Promise> => { - if (appPlane === undefined) { - const { cloudApiHandler } = await import("./app"); - appPlane = cloudApiHandler(); - appGraphEntered = true; +const getAppPlane = (): Promise => { + if (appPlanePromise === undefined) { + appPlanePromise = import("./app").then( + ({ cloudApiHandler }) => { + const plane = cloudApiHandler(); + appGraphEntered = true; + return plane; + }, + (cause: unknown) => { + // Do not memoize a failure: the next request re-imports, as the + // un-memoized version did, instead of failing every request after. + appPlanePromise = undefined; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: re-raise the import failure to the awaiting request + throw cause; + }, + ); } - return appPlane; + return appPlanePromise; +}; + +// --------------------------------------------------------------------------- +// Pre-warming the app plane. +// --------------------------------------------------------------------------- +// +// Measured on production 2026-09-18: 30% of `/api/*` dispatches landed on an +// isolate that had not yet evaluated the app graph, and paid ~2s (p50) for it +// against ~100ms warm. Only 43% of those isolates were under 5s old. The rest +// had been alive for seconds to minutes serving `/mcp`, discovery documents, +// or proxies, none of which enter the app graph, so the dashboard's first +// call was the one that paid. Isolates live about a minute at the median, so +// there is rarely a second dashboard request to benefit. +// +// So any request that does NOT need the app plane starts its import in the +// background. The request itself returns as before; the import runs under +// `waitUntil`, so the isolate stays up until it finishes. A dashboard call +// arriving afterwards finds the graph evaluated. The truly fresh isolate +// (first request IS a dashboard call) still pays; that cost is the graph's +// evaluation itself, addressed separately. +// +// Failure is swallowed on purpose: a pre-warm that fails must not fail the +// request that triggered it, and the next real app-plane request re-imports +// through the same memo and surfaces the error where it belongs. +const prewarmAppPlane = (ctx: ExecutionContext): void => { + if (appGraphEntered) return; + ctx.waitUntil( + getAppPlane().then( + () => undefined, + () => undefined, + ), + ); }; const cloudflareHandler: ExportedHandler = { @@ -266,6 +311,12 @@ const cloudflareHandler: ExportedHandler = { // import loads the entire React + Effect server graph and can take seconds // on a cold isolate. Classify and service-bind marketing at the Worker // entry, before telemetry or fetchHandler touches that graph. + // Everything that returns before the app-plane dispatch below leaves the + // graph unevaluated for the next request; warm it in the background. + if (!servedByAppPlane(new URL(request.url).pathname, request.method)) { + prewarmAppPlane(ctx); + } + const marketingRequest = marketingProxyRequest(request); const marketing: Fetcher | undefined = env.MARKETING; if (marketingRequest && marketing) return marketing.fetch(marketingRequest); @@ -445,6 +496,9 @@ const cloudflareHandler: ExportedHandler = { // isolate goes idle. scheduled: async (_controller, _env, ctx) => { installTracerProvider(); + // The cron fires every minute, often on an isolate that has served no + // dashboard request yet: the cheapest pre-warm there is. + prewarmAppPlane(ctx); await runWorkOsEventsSync(); ctx.waitUntil(flushTracerProvider()); }, diff --git a/apps/cloud/src/web/components/admin-verification.tsx b/apps/cloud/src/web/components/admin-verification.tsx new file mode 100644 index 0000000000..2662a22f56 --- /dev/null +++ b/apps/cloud/src/web/components/admin-verification.tsx @@ -0,0 +1,278 @@ +import { Link } from "@tanstack/react-router"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { Cause, Data, Effect, Exit, Option, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; +import { getExecutorOrganizationHeaders } from "@executor-js/react/api/server-connection"; +import { useAuth } from "../auth"; + +const Status = Schema.Union([ + Schema.Struct({ state: Schema.Literal("required") }), + Schema.Struct({ state: Schema.Literal("verified"), expiresAt: Schema.Number }), +]); +const Challenge = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("challenge") }), + Schema.Struct({ + kind: Schema.Literal("enroll"), + secret: Schema.String, + qrCode: Schema.String.check(Schema.isPattern(/^data:image\/png;base64,/)), + }), +]); +const Message = Schema.Struct({ message: Schema.String }); +const Verified = Schema.Struct({ verified: Schema.Literal(true) }); +const Canceled = Schema.Struct({ canceled: Schema.Literal(true) }); +const unavailable = "Verification is unavailable. Try again."; + +class VerificationError extends Data.TaggedError("VerificationError")<{ + readonly message: string; +}> {} +const decodeStatus = Schema.decodeUnknownOption(Status); +const decodeChallenge = Schema.decodeUnknownOption(Challenge); +const decodeMessage = Schema.decodeUnknownOption(Message); +const decodeVerified = Schema.decodeUnknownOption(Verified); +const decodeCanceled = Schema.decodeUnknownOption(Canceled); + +function request( + path: string, + decode: (value: unknown) => Option.Option, + body?: Readonly>, +): Effect.Effect { + return Effect.gen(function* () { + const { response, raw } = yield* Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const url = `/api/auth/admin-mfa${path}`; + const base = body === undefined ? HttpClientRequest.get(url) : HttpClientRequest.post(url); + const payload = body === undefined ? base : yield* HttpClientRequest.bodyJson(base, body); + const response = yield* client.execute( + HttpClientRequest.setHeaders(payload, getExecutorOrganizationHeaders()), + ); + const raw = yield* response.json; + return { response, raw }; + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.mapError(() => new VerificationError({ message: unavailable })), + ); + if (response.status < 200 || response.status >= 300) { + const message = Option.getOrNull(decodeMessage(raw)); + return yield* new VerificationError({ message: message?.message ?? unavailable }); + } + const parsed = decode(raw); + if (Option.isNone(parsed)) return yield* new VerificationError({ message: unavailable }); + return parsed.value; + }); +} + +/** Require a second factor before mounting the organization settings page. */ +export function AdminVerification({ children }: { readonly children: ReactNode }) { + const auth = useAuth(); + const scope = auth.status === "authenticated" ? auth.organization?.id : undefined; + if (!scope) return null; + return {children}; +} + +function VerificationFlow({ children }: { readonly children: ReactNode }) { + const [status, setStatus] = useState(null); + const [challenge, setChallenge] = useState(null); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const controller = useRef(null); + const codeId = useId(); + + const run = async ( + effect: Effect.Effect, + signal: AbortSignal, + onSuccess: (value: A) => void, + ) => { + const exit = await Effect.runPromiseExit(effect, { signal }); + if (signal.aborted) return; + if (Exit.isSuccess(exit)) onSuccess(exit.value); + else setError(Option.getOrNull(Cause.findErrorOption(exit.cause))?.message ?? unavailable); + }; + + useEffect(() => { + const owner = new AbortController(); + controller.current = owner; + const load = () => run(request("", decodeStatus), owner.signal, setStatus); + void load(); + window.addEventListener("focus", load); + return () => { + owner.abort(); + window.removeEventListener("focus", load); + }; + }, []); + + useEffect(() => { + if (status?.state !== "verified") return; + const timeout = window.setTimeout( + () => { + setStatus({ state: "required" }); + setChallenge(null); + setCode(""); + }, + Math.max(0, status.expiresAt * 1000 - Date.now()), + ); + return () => window.clearTimeout(timeout); + }, [status]); + + const act = async (action: "start" | "verify" | "cancel" | "retry" | "lock") => { + const signal = controller.current?.signal; + if (!signal || signal.aborted || busy) return; + setBusy(true); + setError(null); + if (action === "lock") { + await run(request("/lock", decodeCanceled, {}), signal, () => window.location.reload()); + } else if (action === "start") { + await run(request("/start", decodeChallenge, {}), signal, (next) => { + setChallenge(next); + setCode(""); + }); + } else if (action === "verify") { + await run(request("/verify", decodeVerified, { code }), signal, () => { + setChallenge(null); + setCode(""); + // Reload clears cached admin requests and the enrollment secret while + // retaining the current organization's URL. + window.location.reload(); + }); + } else if (action === "cancel") { + await run(request("/cancel", decodeCanceled, {}), signal, () => { + setChallenge(null); + setCode(""); + }); + } else { + await run(request("", decodeStatus), signal, setStatus); + } + if (!signal.aborted) setBusy(false); + }; + + if (status?.state === "verified") + return ( + <> +
+ Organization settings are unlocked for this session.{" "} + +
+ {children} + + ); + + return ( +
+

Unlock organization settings

+

+ Use an authenticator app to open organization settings for this session. +

+ previous} + className="mt-3 block text-sm underline" + > + Back to workspace + + {error && ( +

+ {error} +

+ )} + {!status ? ( +
+ {error ? ( + + ) : ( +

Checking access…

+ )} +
+ ) : challenge ? ( +
{ + event.preventDefault(); + void act("verify"); + }} + > + {challenge.kind === "enroll" && ( +
+

Scan this code with your authenticator app.

+ Authenticator setup QR code +
+ Enter a setup key instead +

{challenge.secret}

+
+
+ )} +
+ + setCode(event.target.value.replace(/\D/g, ""))} + /> +
+
+ + + +
+ {challenge.kind === "challenge" && ( +

+ Lost your authenticator?{" "} + + Contact support + + . +

+ )} +
+ ) : ( + + )} +
+ ); +} diff --git a/apps/cloud/test-stubs/verified-settings.ts b/apps/cloud/test-stubs/verified-settings.ts new file mode 100644 index 0000000000..d5f2f17200 --- /dev/null +++ b/apps/cloud/test-stubs/verified-settings.ts @@ -0,0 +1,23 @@ +import { env } from "cloudflare:workers"; +import { Effect } from "effect"; +import { ADMIN_MFA_COOKIE, signAdminMfaProof } from "../src/auth/admin-mfa-proof"; + +/** A signed proof for HTTP fixtures; the WorkOS stub must return this session id. */ +export const verifiedSettingsCookie = async (userId: string): Promise => { + const now = Date.now(); + const proof = await Effect.runPromise( + signAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId, sessionId: "test-settings-session" }, + "verified", + { + factorId: "test-factor", + challengeId: "test-challenge", + mode: "challenge", + exp: now / 1000 + 900, + }, + now, + ), + ); + return `${ADMIN_MFA_COOKIE}=${proof}`; +}; diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 6d92064589..4ee8b7a2b4 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -28,6 +28,13 @@ "observability": { "enabled": true, }, + "ratelimits": [ + { + "name": "ADMIN_MFA_RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 5, "period": 60 }, + }, + ], // Script-level logpush feeds the account's workers_trace_events Logpush job // (invocation logs, outcomes like exceededMemory, console output) into // Axiom. Pinned here because the setting lives on the script: a deploy that diff --git a/apps/desktop/CHANGELOG.md b/apps/desktop/CHANGELOG.md index 01d54c3530..28c1dd5bcf 100644 --- a/apps/desktop/CHANGELOG.md +++ b/apps/desktop/CHANGELOG.md @@ -1,5 +1,17 @@ # @executor-js/desktop +## 1.6.10 + +### Patch Changes + +- [#2049](https://github.com/UsefulSoftwareCo/executor/pull/2049) [`dc0808d`](https://github.com/UsefulSoftwareCo/executor/commit/dc0808d5ca9716d73e4def9365f49d4c1afd4af9) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Fix macOS auto-update. The 1.6.9 update zip was built with a 7-Zip that + expanded the framework symlinks into copies, so the extracted app failed code + signing and Squirrel.Mac silently refused to install it; "Restart to update" + appeared to do nothing. electron-builder is bumped to a release that preserves + symlinks, the publish job now verifies the zip's signature before uploading, + and a rejected install surfaces as "Update failed" instead of leaving the card + untouched. + ## 1.6.9 ## 1.6.8 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 47faabf240..0c17148dd4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/desktop", - "version": "1.6.9", + "version": "1.6.10", "private": true, "homepage": "https://github.com/UsefulSoftwareCo/executor", "license": "MIT", @@ -48,7 +48,7 @@ "@zip.js/zip.js": "^2.8.26", "bun-types": "catalog:", "electron": "41.10.3", - "electron-builder": "^26", + "electron-builder": "26.16.1", "electron-vite": "^5", "quickjs-emscripten": "catalog:", "typescript": "catalog:", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 98f9ec7b88..0168684676 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { app, + autoUpdater as nativeAutoUpdater, BrowserWindow, dialog, ipcMain, @@ -806,17 +807,11 @@ const registerIpcHandlers = () => { // Outside a packaged build there is no real bundle to swap, and quitting // would tear down the e2e harness — reflect "installing" so the renderer // can prove the wiring instead. - if (!app.isPackaged) { - setUpdateStatus({ state: "installing", version }); - return; - } - // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle, matching - // the native dialog's restart path. - stopSupervisedMonitor(); - if (connection) { - await stopConnection(connection); - connection = null; - } + setUpdateStatus({ state: "installing", version }); + if (!app.isPackaged) return; + // Squirrel.Mac only validates the staged bundle now; the sidecar is torn + // down in 'before-quit-for-update' once it has accepted the update, so a + // rejected zip leaves the app usable and surfaces via the 'error' handler. autoUpdater.quitAndInstall(false, true); }); // Crash-screen last resort for damaged state: confirm, move the data dir @@ -937,13 +932,7 @@ const promptInstallUpdate = async (version: string) => { cancelId: 1, }); if (response.response === 0) { - // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. A - // supervised daemon is left running — it's independent of this bundle. - stopSupervisedMonitor(); - if (connection) { - await stopConnection(connection); - connection = null; - } + setUpdateStatus({ state: "installing", version }); autoUpdater.quitAndInstall(false, true); return; } @@ -963,6 +952,18 @@ const setupAutoUpdater = () => { autoUpdater.logger = log; autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = false; + // Fired by Electron's native updater once Squirrel.Mac has downloaded and + // validated the bundle and is about to quit for the swap. Stop a spawned + // sidecar here rather than before quitAndInstall: if Squirrel rejects the + // update (bad signature, corrupt zip) nothing has been torn down. A + // supervised daemon is left running — it's independent of this bundle. + nativeAutoUpdater.on("before-quit-for-update", () => { + stopSupervisedMonitor(); + if (connection) { + void stopConnection(connection); + connection = null; + } + }); autoUpdater.on("update-available", (info: UpdateInfo) => { pendingUpdateVersion = info.version; diff --git a/apps/desktop/src/main/updater-state.test.ts b/apps/desktop/src/main/updater-state.test.ts index 9809f759cb..388413966d 100644 --- a/apps/desktop/src/main/updater-state.test.ts +++ b/apps/desktop/src/main/updater-state.test.ts @@ -79,6 +79,15 @@ describe("updater state decisions", () => { }); }); + it("moves a staged or installing update to error when Squirrel rejects it", () => { + expect( + statusAfterUpdateError({ state: "downloaded", version: "1.6.9" }, "Update failed"), + ).toEqual({ state: "error", version: "1.6.9", message: "Update failed" }); + expect( + statusAfterUpdateError({ state: "installing", version: "1.6.9" }, "Update failed"), + ).toEqual({ state: "error", version: "1.6.9", message: "Update failed" }); + }); + it("restores autoInstallOnAppQuit only when the fatal path recovers", () => { expect( planFatalAutoInstallOnQuit({ diff --git a/apps/desktop/src/main/updater-state.ts b/apps/desktop/src/main/updater-state.ts index 1739337006..9f3c209a0c 100644 --- a/apps/desktop/src/main/updater-state.ts +++ b/apps/desktop/src/main/updater-state.ts @@ -89,14 +89,17 @@ export const planDownloadedUpdate = (input: DownloadedUpdateInput): DownloadedUp }; }; +// Any state that names a version is an update in flight, including a staged +// ("downloaded") or installing one: Squirrel.Mac validates the bundle only when +// the install starts, so a rejected zip surfaces as an error *after* the card +// already offered "Restart to update". Dropping that error left the card +// unchanged and the click looked like a no-op. export const statusAfterUpdateError = ( status: DesktopUpdateStatus, message: string, ): DesktopUpdateStatus => { - if (status.state === "available" || status.state === "downloading" || status.state === "error") { - return { state: "error", version: status.version, message }; - } - return status; + if (status.state === "idle") return status; + return { state: "error", version: status.version, message }; }; export const planFatalAutoInstallOnQuit = (input: { diff --git a/apps/docs/hosted/docker.mdx b/apps/docs/hosted/docker.mdx index 8fc30f0e85..807951b07f 100644 --- a/apps/docs/hosted/docker.mdx +++ b/apps/docs/hosted/docker.mdx @@ -69,6 +69,7 @@ the container defaults. | `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. | | `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | | `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. | +| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. | Tracing is configured separately, and off unless you turn it on — see [Tracing](/hosted/tracing). diff --git a/apps/host-cloudflare/src/execution.ts b/apps/host-cloudflare/src/execution.ts index 8429f8aa7e..6e2174b7d8 100644 --- a/apps/host-cloudflare/src/execution.ts +++ b/apps/host-cloudflare/src/execution.ts @@ -56,6 +56,11 @@ export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer< allowLocalNetwork: config.allowLocalNetwork, webBaseUrl: config.webBaseUrl, oauthCallbackPath: "/api/oauth/callback", + // Each MCP session's executor lives in a Durable Object with a small memory + // limit, and one rebuild holds a whole catalog (a large OpenAPI spec) in + // memory. Rebuild stale catalogs one at a time so many of them cannot take + // the session down together. + toolsSyncConcurrency: 1, // Absent unless both gateway ids are set, which leaves search lexical. ...(config.jevGateway === undefined ? {} : { jevGateway: config.jevGateway }), }); diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 58aa7f93c3..92f8f263c6 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -12,7 +12,7 @@ import type { ExecutorDbHandle } from "@executor-js/api/server"; import { McpAgentSessionDOBase, type BuiltMcpServer, - type McpApprovalOwner, + type McpModelResumeCaller, type McpSessionModelResumeResult, type McpSessionInit, type SessionMeta, @@ -96,7 +96,7 @@ export class McpSessionDO extends McpAgentSessionDOBase { diff --git a/apps/host-selfhost/.env.example b/apps/host-selfhost/.env.example index 1eb13376a6..3e3296c884 100644 --- a/apps/host-selfhost/.env.example +++ b/apps/host-selfhost/.env.example @@ -36,6 +36,12 @@ # default — adversarial generated code should not reach your internal network. # EXECUTOR_ALLOW_LOCAL_NETWORK=false +# --- Auth rate limiting ------------------------------------------------------- +# Sign-in attempts are rate-limited per client IP. Without a trusted proxy +# header every caller shares one bucket. Set the exact string "true" only when +# something in front of Executor rate-limits instead. +# EXECUTOR_DISABLE_AUTH_RATE_LIMIT=false + # --- Local stdio MCP (trusted deployments only) ------------------------------- # Stdio MCP is disabled unless this is explicitly set to the exact string # "true". Enabling it lets users configure MCP servers whose commands execute diff --git a/apps/host-selfhost/CHANGELOG.md b/apps/host-selfhost/CHANGELOG.md index 222a2029f8..e4d5e8b9ba 100644 --- a/apps/host-selfhost/CHANGELOG.md +++ b/apps/host-selfhost/CHANGELOG.md @@ -1,5 +1,28 @@ # @executor-js/host-selfhost +## 0.0.52 + +### Patch Changes + +- [#2044](https://github.com/UsefulSoftwareCo/executor/pull/2044) [`004024b`](https://github.com/UsefulSoftwareCo/executor/commit/004024b453e9ba07317d2893f050a0d6dae6a67b) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` to the self-host. Better Auth 1.6.17 and later enforce sign-in rate limits strictly in production, and with no trusted proxy header every caller shares one bucket of three sign-ins per ten seconds. The Docker release gate signs in from many test files at once and tripped it. The flag is off by default; the e2e harness sets it for the image it tests. + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/runtime-quickjs@1.6.10 + - @executor-js/execution@1.6.10 + - @executor-js/plugin-graphql@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.17 + - @executor-js/api@1.4.73 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.21 + - @executor-js/plugin-encrypted-secrets@0.0.52 + - @executor-js/plugin-provider-service-split@0.0.24 + - @executor-js/plugin-toolkits@1.5.45 + - @executor-js/react@1.4.73 + ## 0.0.51 ### Patch Changes diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index d91eaae8bf..0f9c804248 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/host-selfhost", - "version": "0.0.51", + "version": "0.0.52", "private": true, "type": "module", "exports": { diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 1d714312fd..4031fdf273 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -130,6 +130,10 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: baseURL: config.webBaseUrl, trustedOrigins: [...config.trustedOrigins], advanced: { useSecureCookies: !hasInsecureTrustedOrigin }, + // Better Auth's own limiter is on in production and off in development. + // Only an explicit opt-out is passed through, so that environment default + // stays in charge everywhere else. + ...(config.authRateLimit ? {} : { rateLimit: { enabled: false } }), emailAndPassword: { enabled: true }, // `apiKey` issues long-lived personal keys (the API-keys page). With // `enableSessionForAPIKeys`, presenting a key resolves to its owner's diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index ab443f3bf3..07dcc56d14 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -54,6 +54,15 @@ export interface SelfHostConfig { * internal network unless an operator opts in. */ readonly allowLocalNetwork: boolean; + /** + * Whether Better Auth rate-limits its own endpoints (sign-in and friends). + * Better Auth turns this on in production and keys the limit on the client + * IP it reads from a trusted proxy header. With no such header every caller + * shares one bucket, so an operator who rate-limits upstream, or an + * automated suite that signs in far faster than a person, turns it off with + * `EXECUTOR_DISABLE_AUTH_RATE_LIMIT=true`. + */ + readonly authRateLimit: boolean; // Better Auth session secret. Always resolved (env, else generated + persisted // under the data dir) so a single-container deploy boots with no env; the auth // layer still validates an explicitly-set env secret is long enough. @@ -187,6 +196,7 @@ export const loadConfig = (): SelfHostConfig => { webBaseUrl, trustedOrigins: resolveTrustedOrigins(webBaseUrl), allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", + authRateLimit: process.env.EXECUTOR_DISABLE_AUTH_RATE_LIMIT !== "true", authSecret: resolveAuthSecret(), bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, diff --git a/apps/host-selfhost/src/executor-config.test.ts b/apps/host-selfhost/src/executor-config.test.ts index 313d097b85..576b93820c 100644 --- a/apps/host-selfhost/src/executor-config.test.ts +++ b/apps/host-selfhost/src/executor-config.test.ts @@ -9,6 +9,8 @@ const TTL_ENV_NAME = "EXECUTOR_TOOLS_SYNC_TTL_MS"; const originalValue = process.env[ENV_NAME]; const originalSecret = process.env[SECRET_ENV_NAME]; const originalTtl = process.env[TTL_ENV_NAME]; +const RATE_LIMIT_ENV_NAME = "EXECUTOR_DISABLE_AUTH_RATE_LIMIT"; +const originalRateLimit = process.env[RATE_LIMIT_ENV_NAME]; beforeEach(() => { process.env[SECRET_ENV_NAME] = originalSecret ?? "executor-config-test-secret"; @@ -30,6 +32,11 @@ afterEach(() => { } else { process.env[TTL_ENV_NAME] = originalTtl; } + if (originalRateLimit === undefined) { + delete process.env[RATE_LIMIT_ENV_NAME]; + } else { + process.env[RATE_LIMIT_ENV_NAME] = originalRateLimit; + } }); const allowStdio = (): boolean => { @@ -112,3 +119,15 @@ test("a negative tools-sync TTL refuses to boot", () => { process.env[TTL_ENV_NAME] = "-1"; expect(() => loadConfig()).toThrow(/must not be negative/); }); + +test("auth rate limiting stays on unless the opt-out is exactly true", () => { + delete process.env[RATE_LIMIT_ENV_NAME]; + expect(loadConfig().authRateLimit).toBe(true); + process.env[RATE_LIMIT_ENV_NAME] = "TRUE"; + expect(loadConfig().authRateLimit).toBe(true); +}); + +test("auth rate limiting is off when the opt-out is exactly true", () => { + process.env[RATE_LIMIT_ENV_NAME] = "true"; + expect(loadConfig().authRateLimit).toBe(false); +}); diff --git a/apps/local/CHANGELOG.md b/apps/local/CHANGELOG.md index a13966fc3a..54955cdca3 100644 --- a/apps/local/CHANGELOG.md +++ b/apps/local/CHANGELOG.md @@ -1,5 +1,32 @@ # @executor-js/local +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/runtime-quickjs@1.6.10 + - @executor-js/execution@1.6.10 + - @executor-js/config@1.6.10 + - @executor-js/plugin-file-secrets@1.6.10 + - @executor-js/plugin-graphql@1.6.10 + - @executor-js/plugin-keychain@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-onepassword@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/plugin-example@1.6.10 + - @executor-js/plugin-desktop-settings@1.6.10 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.17 + - @executor-js/api@1.4.73 + - @executor-js/vite-plugin@0.0.70 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.21 + - @executor-js/plugin-provider-service-split@0.0.24 + - @executor-js/plugin-toolkits@1.5.45 + - @executor-js/react@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/apps/local/package.json b/apps/local/package.json index c1df3307be..138f4cc37f 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/local", - "version": "1.6.9", + "version": "1.6.10", "private": true, "type": "module", "exports": { diff --git a/bun.lock b/bun.lock index 02fa988dae..78ee6bcded 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,7 @@ }, "apps/cli": { "name": "executor", - "version": "1.6.9", + "version": "1.6.10", "bin": { "executor": "./bin/executor.ts", }, @@ -60,7 +60,7 @@ }, "apps/cloud": { "name": "@executor-js/cloud", - "version": "1.4.70", + "version": "1.4.71", "dependencies": { "@cloudflare/vite-plugin": "^1.31.1", "@effect/atom-react": "catalog:", @@ -133,7 +133,7 @@ }, "apps/desktop": { "name": "@executor-js/desktop", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@sentry/bun": "^10.57.0", "@sentry/electron": "7.19.0", @@ -161,7 +161,7 @@ "@zip.js/zip.js": "^2.8.26", "bun-types": "catalog:", "electron": "41.10.3", - "electron-builder": "^26", + "electron-builder": "26.16.1", "electron-vite": "^5", "quickjs-emscripten": "catalog:", "typescript": "catalog:", @@ -220,7 +220,7 @@ }, "apps/host-selfhost": { "name": "@executor-js/host-selfhost", - "version": "0.0.51", + "version": "0.0.52", "dependencies": { "@better-auth/api-key": "^1.6.11", "@cloudflare/worker-bundler": "0.2.1", @@ -273,7 +273,7 @@ }, "apps/local": { "name": "@executor-js/local", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@effect/atom-react": "catalog:", "@effect/platform-node": "catalog:", @@ -353,10 +353,10 @@ }, "e2e": { "name": "@executor-js/e2e", - "version": "0.0.49", + "version": "0.0.50", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.2", + "@executor-js/emulate": "0.14.3-mfa.0", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -381,6 +381,7 @@ "@vitejs/plugin-react": "catalog:", "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", + "otpauth": "^9.5.2", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", @@ -388,7 +389,7 @@ }, "examples/all-plugins": { "name": "@executor-js/example-all-plugins", - "version": "0.0.70", + "version": "0.0.71", "dependencies": { "@executor-js/plugin-file-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -407,7 +408,7 @@ }, "examples/docs-sdk-quickstart": { "name": "@executor-js/example-docs-sdk-quickstart", - "version": "0.0.55", + "version": "0.0.56", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -464,7 +465,7 @@ }, "packages/core/analytics": { "name": "@executor-js/analytics", - "version": "0.1.16", + "version": "0.1.17", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/execution": "workspace:*", @@ -480,7 +481,7 @@ }, "packages/core/api": { "name": "@executor-js/api", - "version": "1.4.72", + "version": "1.4.73", "dependencies": { "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", @@ -497,7 +498,7 @@ }, "packages/core/cli": { "name": "@executor-js/cli", - "version": "0.2.59", + "version": "0.2.60", "bin": { "executor-sdk": "./dist/index.js", }, @@ -518,7 +519,7 @@ }, "packages/core/config": { "name": "@executor-js/config", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/sdk": "workspace:*", "jiti": "^2.6.1", @@ -539,7 +540,7 @@ }, "packages/core/execution": { "name": "@executor-js/execution", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/codemode-core": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -605,7 +606,7 @@ }, "packages/core/sdk": { "name": "@executor-js/sdk", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/fumadb": "workspace:*", "@standard-schema/spec": "^1.1.0", @@ -658,7 +659,7 @@ }, "packages/core/vite-plugin": { "name": "@executor-js/vite-plugin", - "version": "0.0.69", + "version": "0.0.70", "dependencies": { "@executor-js/sdk": "workspace:*", "jiti": "^2.6.1", @@ -678,7 +679,7 @@ }, "packages/hosts/cloudflare": { "name": "@executor-js/cloudflare", - "version": "0.0.51", + "version": "0.0.52", "dependencies": { "@executor-js/api": "workspace:*", "@executor-js/execution": "workspace:*", @@ -719,7 +720,7 @@ }, "packages/hosts/mcp-apps-shell": { "name": "@executor-js/mcp-apps-shell", - "version": "1.4.20", + "version": "1.4.21", "dependencies": { "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", @@ -757,7 +758,7 @@ }, "packages/kernel/core": { "name": "@executor-js/codemode-core", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@babel/parser": "^7.29.2", "@standard-schema/spec": "^1.0.0", @@ -830,7 +831,7 @@ }, "packages/kernel/runtime-quickjs": { "name": "@executor-js/runtime-quickjs", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/codemode-core": "workspace:*", "quickjs-emscripten": "catalog:", @@ -850,7 +851,7 @@ }, "packages/kernel/runtime-workerd-subprocess": { "name": "@executor-js/runtime-workerd-subprocess", - "version": "0.0.24", + "version": "0.0.25", "dependencies": { "@executor-js/codemode-core": "workspace:*", "effect": "catalog:", @@ -865,7 +866,7 @@ }, "packages/onboarding-demo": { "name": "@executor-js/onboarding-demo", - "version": "0.0.4", + "version": "0.0.5", "dependencies": { "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", @@ -889,7 +890,7 @@ }, "packages/plugins/desktop-settings": { "name": "@executor-js/plugin-desktop-settings", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/sdk": "workspace:*", "react": "catalog:", @@ -902,7 +903,7 @@ }, "packages/plugins/encrypted-secrets": { "name": "@executor-js/plugin-encrypted-secrets", - "version": "0.0.51", + "version": "0.0.52", "dependencies": { "@executor-js/sdk": "workspace:*", "effect": "catalog:", @@ -917,7 +918,7 @@ }, "packages/plugins/example": { "name": "@executor-js/plugin-example", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -940,7 +941,7 @@ }, "packages/plugins/file-secrets": { "name": "@executor-js/plugin-file-secrets", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -957,7 +958,7 @@ }, "packages/plugins/graphql": { "name": "@executor-js/plugin-graphql", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", @@ -996,7 +997,7 @@ }, "packages/plugins/keychain": { "name": "@executor-js/plugin-keychain", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@executor-js/sdk": "workspace:*", "@napi-rs/keyring": "^1.2.0", @@ -1015,7 +1016,7 @@ }, "packages/plugins/mcp": { "name": "@executor-js/plugin-mcp", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@cfworker/json-schema": "^4.1.1", "@effect/platform-node": "catalog:", @@ -1059,7 +1060,7 @@ }, "packages/plugins/onepassword": { "name": "@executor-js/plugin-onepassword", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@1password/sdk": "^0.4.1-beta.1", "@effect/atom-react": "catalog:", @@ -1092,7 +1093,7 @@ }, "packages/plugins/openapi": { "name": "@executor-js/plugin-openapi", - "version": "1.6.9", + "version": "1.6.10", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", @@ -1133,7 +1134,7 @@ }, "packages/plugins/provider-service-split": { "name": "@executor-js/plugin-provider-service-split", - "version": "0.0.23", + "version": "0.0.24", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -1150,7 +1151,7 @@ }, "packages/plugins/toolkits": { "name": "@executor-js/plugin-toolkits", - "version": "1.5.44", + "version": "1.5.45", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -1219,7 +1220,7 @@ }, "packages/react": { "name": "@executor-js/react", - "version": "1.4.72", + "version": "1.4.73", "dependencies": { "@base-ui/react": "^1.3.0", "@effect/atom-react": "catalog:", @@ -1687,7 +1688,7 @@ "@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="], - "@electron/rebuild": ["@electron/rebuild@4.0.3", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "got": "^11.7.0", "graceful-fs": "^4.2.11", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^11.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^7.5.6", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA=="], + "@electron/rebuild": ["@electron/rebuild@4.0.4", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg=="], "@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="], @@ -1811,7 +1812,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.14.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-rUzfQFq1dO3qwzW83jL7kEikLLPXTjqLTSU9qpVdbYyqMF/Ef8YgwH+hw0tbqNEkuGFwuZKkMkDUPPfkidMamg=="], + "@executor-js/emulate": ["@executor-js/emulate@0.14.3-mfa.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "jose": "^6", "openid-client": "^6.8.4", "otpauth": "9.5.2", "picocolors": "^1.1.1", "qrcode": "1.5.4", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-XdXLM+Q5lWtfkgAqa95atZmpKAjel9A29ulJeDCgMlKwPerQJ1d8yWsAn5b3iq68Bq5HGwtjZt7ebBuwZP3dBw=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], @@ -2221,7 +2222,7 @@ "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], - "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -2229,10 +2230,6 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="], - - "@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], - "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], @@ -3309,7 +3306,7 @@ "@zip.js/zip.js": ["@zip.js/zip.js@2.8.26", "", {}, "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA=="], - "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -3357,7 +3354,7 @@ "app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="], - "app-builder-lib": ["app-builder-lib@26.15.0", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.0", "electron-builder-squirrel-windows": "26.15.0" } }, "sha512-j2+P6Lh+l/VuWfXZWSs7u+OAPqYJQGnZZO30M833XQQaRuyohm4RZk7Gw4nQXfeyQH9GqXaTwR16Y0LaVTlS+g=="], + "app-builder-lib": ["app-builder-lib@26.16.1", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^1.8.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.16.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.16.1", "electron-builder-squirrel-windows": "26.16.1" } }, "sha512-FhaO6YOup01ZfQW0Z6gt3AyukJjv1gW4uFK47jTgwcHZKqyN/fSlK2LqPf9tAeZYLP2bRJLDzeOkRImsw2X4Pg=="], "app-root-path": ["app-root-path@3.1.0", "", {}, "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA=="], @@ -3485,7 +3482,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "builder-util": ["builder-util@26.15.0", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA=="], + "builder-util": ["builder-util@26.16.0", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg=="], "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], @@ -3513,8 +3510,6 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], - "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], @@ -3527,6 +3522,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], @@ -3771,6 +3768,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -3787,8 +3786,6 @@ "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], - "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], - "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], @@ -3827,11 +3824,13 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], - "dmg-builder": ["dmg-builder@26.15.0", "", { "dependencies": { "app-builder-lib": "26.15.0", "builder-util": "26.15.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-oS8MWttbpIUF/2v8LOEY+f4ayL84ipMOarZvdRMl/pxlhLxAYjYMklTXHEXIl37Ig+qJv/bVF7HgyIoOoZyMWA=="], + "dmg-builder": ["dmg-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw=="], "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], @@ -3875,13 +3874,13 @@ "electron": ["electron@41.10.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js" } }, "sha512-MJuSODPw8siv/I8JjhctW/cS/XNldwI4gLRyyWZx6QkoZJUDgbEvitp7IVOnGrHENTQb6Udo+zMpKhFnhlIhdg=="], - "electron-builder": ["electron-builder@26.15.0", "", { "dependencies": { "app-builder-lib": "26.15.0", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.0", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-zd4cfvjHmtyGqMaDudg5rAjNUkwIJDz8ICaCsz77hFKcjMQHcZNNNCs/C4phwN9+gEVwmhvpKMzNFum6fs/n6A=="], + "electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="], "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="], "electron-log": ["electron-log@5.4.3", "", {}, "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ=="], - "electron-publish": ["electron-publish@26.15.0", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-pt6K3ol/a+o3HbqmYkL2NYlVH5pd34tL4FPRcgX8E88xQAqQyIsseXe4vWy7Pq2BaYy+iFGJrtInZe11FFAQwQ=="], + "electron-publish": ["electron-publish@26.16.0", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q=="], "electron-store": ["electron-store@10.1.0", "", { "dependencies": { "conf": "^14.0.0", "type-fest": "^4.41.0" } }, "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w=="], @@ -3907,8 +3906,6 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], - "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -4113,8 +4110,6 @@ "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], - "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -4285,8 +4280,6 @@ "import-in-the-middle": ["import-in-the-middle@3.0.2", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-LGLYRl0A2gtyUJb2WDliBHmk6TtlHwdDjxonacZ8QrEs/ZW+YDgNv2QAfjRQWpS8HqvNcq6GGnN6jrOa5FysDQ=="], - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], @@ -4593,8 +4586,6 @@ "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], - "make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="], - "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -4757,16 +4748,6 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], - - "minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="], - - "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], - - "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], - - "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], - "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], "mixin-deep": ["mixin-deep@1.3.2", "", { "dependencies": { "for-in": "^1.0.2", "is-extendable": "^1.0.1" } }, "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA=="], @@ -4831,7 +4812,7 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], - "node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], + "node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], @@ -4841,7 +4822,7 @@ "node-releases": ["node-releases@2.0.55", "", {}, "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ=="], - "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -4899,6 +4880,8 @@ "ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="], + "otpauth": ["otpauth@9.5.2", "", { "dependencies": { "@noble/hashes": "2.4.0" } }, "sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "oxc-parser": ["oxc-parser@0.121.0", "", { "dependencies": { "@oxc-project/types": "^0.121.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.121.0", "@oxc-parser/binding-android-arm64": "0.121.0", "@oxc-parser/binding-darwin-arm64": "0.121.0", "@oxc-parser/binding-darwin-x64": "0.121.0", "@oxc-parser/binding-freebsd-x64": "0.121.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.121.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.121.0", "@oxc-parser/binding-linux-arm64-gnu": "0.121.0", "@oxc-parser/binding-linux-arm64-musl": "0.121.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-musl": "0.121.0", "@oxc-parser/binding-linux-s390x-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-musl": "0.121.0", "@oxc-parser/binding-openharmony-arm64": "0.121.0", "@oxc-parser/binding-wasm32-wasi": "0.121.0", "@oxc-parser/binding-win32-arm64-msvc": "0.121.0", "@oxc-parser/binding-win32-ia32-msvc": "0.121.0", "@oxc-parser/binding-win32-x64-msvc": "0.121.0" } }, "sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg=="], @@ -5019,7 +5002,7 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], - "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -5061,7 +5044,7 @@ "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], - "proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], @@ -5101,6 +5084,8 @@ "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -5279,6 +5264,8 @@ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], @@ -5365,6 +5352,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -5417,10 +5406,6 @@ "smol-toml": ["smol-toml@1.7.1", "", {}, "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ=="], - "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], - - "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], - "solid-js": ["solid-js@1.9.13", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ=="], "solid-transition-group": ["solid-transition-group@0.2.3", "", { "dependencies": { "@solid-primitives/refs": "^1.0.5", "@solid-primitives/transition-group": "^1.0.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg=="], @@ -5453,8 +5438,6 @@ "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], - "ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="], - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -5675,10 +5658,6 @@ "unifont": ["unifont@0.7.5", "", { "dependencies": { "css-tree": "^3.1.0", "ohash": "^2.0.11", "undici": "^8.0.0" } }, "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg=="], - "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], - - "unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="], - "unique-string": ["unique-string@3.0.0", "", { "dependencies": { "crypto-random-string": "^4.0.0" } }, "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ=="], "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], @@ -5765,8 +5744,6 @@ "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], - "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], - "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -5791,6 +5768,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -5907,6 +5886,8 @@ "@better-auth/core/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@better-auth/utils/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -5967,11 +5948,7 @@ "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], - "@electron/rebuild/node-abi": ["node-abi@4.35.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q=="], - - "@electron/rebuild/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], - - "@electron/rebuild/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@electron/rebuild/node-abi": ["node-abi@4.31.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw=="], "@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], @@ -6001,6 +5978,8 @@ "@executor-js/emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "@executor-js/emulate/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "@executor-js/example-all-plugins/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], @@ -6009,6 +5988,8 @@ "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "@executor-js/host-selfhost/@executor-js/emulate": ["@executor-js/emulate@0.14.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-rUzfQFq1dO3qwzW83jL7kEikLLPXTjqLTSU9qpVdbYyqMF/Ef8YgwH+hw0tbqNEkuGFwuZKkMkDUPPfkidMamg=="], + "@executor-js/mcporter/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@executor-js/mcporter/rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], @@ -6043,6 +6024,8 @@ "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@jimp/js-png/pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -6119,12 +6102,6 @@ "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - - "@npmcli/fs/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "@opentelemetry/configuration/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], @@ -6259,6 +6236,8 @@ "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], @@ -6483,6 +6462,8 @@ "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], + "app-builder-lib/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], "app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -6519,6 +6500,8 @@ "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "better-auth/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "better-auth/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -6541,8 +6524,6 @@ "bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], - "cacache/p-map": ["p-map@7.0.8", "", {}, "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw=="], - "cacheable-request/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], "cheerio/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -6579,8 +6560,6 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], - "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -6601,6 +6580,8 @@ "electron-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "electron-builder/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + "electron-builder-squirrel-windows/app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], "electron-builder-squirrel-windows/builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="], @@ -6621,8 +6602,6 @@ "electron-vite/vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], - "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -6737,12 +6716,6 @@ "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], - "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], "morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -6755,7 +6728,9 @@ "node-gyp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + + "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -6791,6 +6766,8 @@ "protobufjs/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -6843,10 +6820,6 @@ "simple-update-notifier/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "socks/ip-address": ["ip-address@10.7.1", "", {}, "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA=="], - - "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "solid-js/seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -7105,15 +7078,7 @@ "@electron/osx-sign/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "@electron/rebuild/ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "@electron/rebuild/ora/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], - - "@electron/rebuild/ora/is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], - - "@electron/rebuild/ora/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], - - "@electron/rebuild/ora/log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "@electron/rebuild/node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -7171,6 +7136,10 @@ "@executor-js/e2e/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@executor-js/host-selfhost/@executor-js/emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "@executor-js/host-selfhost/@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "@executor-js/mcporter/rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], "@executor-js/mcporter/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], @@ -7619,8 +7588,6 @@ "electron-builder-squirrel-windows/app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild": ["@electron/rebuild@4.0.4", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg=="], - "electron-builder-squirrel-windows/app-builder-lib/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], "electron-builder-squirrel-windows/app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], @@ -7655,6 +7622,10 @@ "electron-builder/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "electron-builder/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "electron-builder/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "electron-publish/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "electron-publish/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -7809,20 +7780,22 @@ "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], - "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - - "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - - "minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "protobufjs/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "request/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -7967,12 +7940,6 @@ "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "@electron/rebuild/ora/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@electron/rebuild/ora/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "@electron/rebuild/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], - "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@executor-js/motel/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], @@ -8031,10 +7998,6 @@ "electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-abi": ["node-abi@4.31.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="], - "electron-builder-squirrel-windows/app-builder-lib/dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -8057,6 +8020,8 @@ "electron-builder-squirrel-windows/builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "electron-builder/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -8079,14 +8044,16 @@ "ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "qrcode/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "superagent/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "typeorm/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "@electron/rebuild/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], @@ -8103,30 +8070,18 @@ "agents/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "googleapis-common/google-auth-library/gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "temp/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/nopt/abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], - - "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild/node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], - "temp/rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/e2e/CHANGELOG.md b/e2e/CHANGELOG.md index 625d796924..aeb68f0a64 100644 --- a/e2e/CHANGELOG.md +++ b/e2e/CHANGELOG.md @@ -1,5 +1,17 @@ # @executor-js/e2e +## 0.0.50 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/plugin-graphql@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/plugin-toolkits@1.5.45 + ## 0.0.49 ### Patch Changes diff --git a/e2e/cloud/admin-mfa-api.test.ts b/e2e/cloud/admin-mfa-api.test.ts new file mode 100644 index 0000000000..e048c9c625 --- /dev/null +++ b/e2e/cloud/admin-mfa-api.test.ts @@ -0,0 +1,131 @@ +import { expect } from "@effect/vitest"; +import { Effect, Option, Schema } from "effect"; +import { TOTP } from "otpauth"; +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; +import { responseCookies } from "./support/admin-mfa"; + +const decodeSetup = Schema.decodeUnknownOption( + Schema.Struct({ kind: Schema.Literal("enroll"), secret: Schema.String }), +); + +scenario( + "Admin MFA API · requires same-origin requests and binds verification to the session", + {}, + Effect.gen(function* () { + const target = yield* Target; + const identity = yield* target.newIdentity(); + const other = yield* target.newIdentity(); + yield* Effect.promise(async () => { + const original = identity.headers?.cookie ?? ""; + const headers = { ...identity.headers, "content-type": "application/json" }; + const unverifiedWorkspace = await fetch(new URL("/api/policies", target.baseUrl), { + headers, + }); + expect(unverifiedWorkspace.status).toBe(200); + const key = await fetch(new URL("/api/account/api-keys", target.baseUrl), { + method: "POST", + headers: { ...headers, origin: new URL(target.baseUrl).origin }, + body: JSON.stringify({ name: "unverified-admin" }), + }); + expect(key.status).toBe(200); + const billing = await fetch(new URL("/api/billing/getOrCreateCustomer", target.baseUrl), { + method: "POST", + headers, + body: "{}", + }); + expect(billing.status).toBe(200); + const post = (action: string, cookie: string, code?: string) => + fetch(new URL(`/api/auth/admin-mfa/${action}`, target.baseUrl), { + method: "POST", + headers: { ...headers, origin: new URL(target.baseUrl).origin, cookie }, + body: JSON.stringify(code === undefined ? {} : { code }), + }); + const noOrigin = await fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { + method: "POST", + headers, + body: "{}", + }); + expect(noOrigin.status).toBe(403); + const crossOrigin = await fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { + method: "POST", + headers: { ...headers, origin: "https://other.example" }, + body: "{}", + }); + expect(crossOrigin.status).toBe(403); + const started = await post("start", original); + expect(started.status).toBe(200); + const setup = Option.getOrNull(decodeSetup(await started.json())); + if (!setup) throw new Error("Expected enrollment setup"); + const pending = responseCookies(original, started); + const challenge = pending + .split("; ") + .find((pair) => pair.startsWith("__Host-executor-admin-challenge=")); + if (!challenge) throw new Error("Expected pending challenge cookie"); + const crossUser = await fetch(new URL("/api/auth/admin-mfa/verify", target.baseUrl), { + method: "POST", + headers: { + ...other.headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + cookie: `${other.headers?.cookie ?? ""}; ${challenge}`, + }, + body: JSON.stringify({ code: new TOTP({ secret: setup.secret }).generate() }), + }); + expect(crossUser.status).toBe(400); + const verified = await post("verify", pending, new TOTP({ secret: setup.secret }).generate()); + expect(verified.status).toBe(200); + const proofCookie = verified.headers + .getSetCookie() + .find((cookie) => cookie.startsWith("__Host-executor-admin-mfa=")); + expect(proofCookie).toMatch(/HttpOnly/i); + expect(proofCookie).toMatch(/Secure/i); + expect(proofCookie).toMatch(/SameSite=Strict/i); + expect(proofCookie).not.toMatch(/Max-Age|Expires/i); + const verifiedCookies = responseCookies(pending, verified); + const status = await fetch(new URL("/api/auth/admin-mfa", target.baseUrl), { + headers: { ...headers, cookie: verifiedCookies }, + }); + expect(await status.json()).toMatchObject({ state: "verified" }); + expect( + (await post("verify", pending, new TOTP({ secret: setup.secret }).generate())).status, + ).toBe(400); + + // A later verification uses the existing factor and never returns its secret. + const repeat = await post("start", original); + expect(await repeat.json()).toEqual({ kind: "challenge" }); + const repeated = await post( + "verify", + responseCookies(original, repeat), + new TOTP({ secret: setup.secret }).generate(), + ); + expect(repeated.status).toBe(200); + }); + }), +); + +scenario( + "Admin MFA API · restarting enrollment cannot reset the rate limit", + {}, + Effect.gen(function* () { + const target = yield* Target; + const identity = yield* target.newIdentity(); + yield* Effect.promise(async () => { + const statuses: number[] = []; + for (let attempt = 0; attempt < 6; attempt++) { + const response = await fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { + method: "POST", + headers: { + ...identity.headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + }, + body: "{}", + }); + statuses.push(response.status); + await response.text(); + } + expect(statuses).toEqual([200, 200, 200, 200, 200, 429]); + }); + }), +); diff --git a/e2e/cloud/connection-owner-isolation.test.ts b/e2e/cloud/connection-owner-isolation.test.ts index 4a04b3bad7..a20a2003f5 100644 --- a/e2e/cloud/connection-owner-isolation.test.ts +++ b/e2e/cloud/connection-owner-isolation.test.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./support/admin-mfa"; // Cloud-only: the connection OWNER model, with real multi-user organizations. // Every connection is filed under `owner: "org"` (shared with the whole tenant) // or `owner: "user"` (this subject's own). The org membership is built through @@ -115,7 +116,8 @@ const orgSelectorOf = (identity: Identity): string => { * Returns the member identity with its requests scoped to that org. */ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => Effect.gen(function* () { - const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { + const verifiedAdmin = yield* verifyAdmin(target.baseUrl, admin); + const inviteResponse = yield* postJson(target, "/api/account/members/invite", verifiedAdmin, { email: member.credentials?.email, }); const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; diff --git a/e2e/cloud/integration-creation-permissions.test.ts b/e2e/cloud/integration-creation-permissions.test.ts new file mode 100644 index 0000000000..44f7b91bb5 --- /dev/null +++ b/e2e/cloud/integration-creation-permissions.test.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect"; +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; +import { integrationCreationPermissions } from "../src/integration-creation-permissions"; +import { forBrowser, joinOrg } from "./support/session"; + +scenario( + "Integration creation · cloud members see admin guidance and admins can add", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + yield* integrationCreationPermissions(forBrowser(admin), forBrowser(member)); + }), +); diff --git a/e2e/cloud/mcp-workos-blip-session-survival.test.ts b/e2e/cloud/mcp-workos-blip-session-survival.test.ts index d6a5022aa5..30ae7d3d31 100644 --- a/e2e/cloud/mcp-workos-blip-session-survival.test.ts +++ b/e2e/cloud/mcp-workos-blip-session-survival.test.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./support/admin-mfa"; // Cloud: an MCP session's relationship to WorkOS after the membership mirror. // // Membership is authorized from the local mirror on every /mcp request @@ -181,7 +182,7 @@ scenario( // An admin's org with one plain member, joined through the real invite → // accept flow. The member is the one whose access is revoked. - const admin = yield* target.newIdentity(); + const admin = yield* verifyAdmin(target.baseUrl, yield* target.newIdentity()); const invitee = yield* target.newIdentity({ org: false }); const member = yield* joinOrg(target, admin, invitee); const bearer = yield* mcp.mintBearer(emailOf(member)); diff --git a/e2e/cloud/member-invite-seat-limit.test.ts b/e2e/cloud/member-invite-seat-limit.test.ts index e465c1396e..ee76349525 100644 --- a/e2e/cloud/member-invite-seat-limit.test.ts +++ b/e2e/cloud/member-invite-seat-limit.test.ts @@ -1,3 +1,4 @@ +import { verifyAdminInBrowser } from "./support/admin-mfa"; // Cloud-only (billing): the free plan advertises "Up to 3 members", 3 // INCLUSIVE. A fresh org's admin holds seat 1, so two invites fill seats 2 and // 3; at that point "Invite member" opens an upgrade prompt (linking to billing) @@ -40,6 +41,7 @@ scenario( // A fresh user who owns a brand-new free org: the admin holds seat 1. const identity = yield* target.newIdentity(); + let verifiedCookie = ""; const client = yield* apiClient(AccountHttpApi, identity); yield* browser.session(identity, async ({ page, step }) => { @@ -56,6 +58,10 @@ scenario( await step("Open the organization members page", async () => { await visit(page, `/${slug}/org`); + await verifyAdminInBrowser(page); + verifiedCookie = (await page.context().cookies()) + .map(({ name, value }) => `${name}=${value}`) + .join("; "); await page.getByRole("button", { name: "Invite member" }).waitFor(); }); @@ -114,7 +120,11 @@ scenario( const refused = yield* Effect.promise(() => fetch(new URL("/api/account/members/invite", target.baseUrl), { method: "POST", - headers: { ...(identity.headers ?? {}), "content-type": "application/json" }, + headers: { + ...(identity.headers ?? {}), + cookie: verifiedCookie, + "content-type": "application/json", + }, body: JSON.stringify({ email: "over-the-cap@example.com" }), }), ); diff --git a/e2e/cloud/member-seat-billing-sync.test.ts b/e2e/cloud/member-seat-billing-sync.test.ts index 28922a6dba..059d08d2e4 100644 --- a/e2e/cloud/member-seat-billing-sync.test.ts +++ b/e2e/cloud/member-seat-billing-sync.test.ts @@ -23,6 +23,7 @@ import { AccountHttpApi } from "@executor-js/api"; import { scenario } from "../src/scenario"; import { Api, Autumn, Billing, Mcp, Target } from "../src/services"; import type { Identity } from "../src/target"; +import { verifyAdmin } from "./support/admin-mfa"; // apps/cloud/src/extensions/billing/plans.ts → MEMBER_LIMITS.free, mirrored by // the free plan's members item in autumn.config.ts. @@ -56,7 +57,8 @@ scenario( const identity = yield* target.newIdentity(); const bearer = yield* mcp.mintBearer(emailOf(identity)); const customerId = orgIdOf(bearer); - const client = yield* apiClient(AccountHttpApi, identity); + const verifiedIdentity = yield* verifyAdmin(target.baseUrl, identity); + const client = yield* apiClient(AccountHttpApi, verifiedIdentity); const seats = yield* autumn.expectMemberSeats(customerId, 1); expect(seats.granted, "the free plan's members item grants the advertised seats").toBe( diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts index bb3f9f187c..c096c2ca5b 100644 --- a/e2e/cloud/org-delete.test.ts +++ b/e2e/cloud/org-delete.test.ts @@ -1,3 +1,4 @@ +import { verifyAdminInBrowser } from "./support/admin-mfa"; // Cloud-specific (browser): an admin permanently deletes their organization. // A fresh user creates an org through onboarding, opens Organization settings, // and uses the danger-zone "Delete organization" flow — which requires @@ -40,6 +41,7 @@ scenario( await step("Open Organization settings and find the danger zone", async () => { await visit(page, `/${slug}/org`); + await verifyAdminInBrowser(page); // The admin-only danger zone renders (a member would not see it). await page.getByText("Permanently delete this organization").waitFor(); }); diff --git a/e2e/cloud/org-settings-mfa.test.ts b/e2e/cloud/org-settings-mfa.test.ts new file mode 100644 index 0000000000..6647b3d143 --- /dev/null +++ b/e2e/cloud/org-settings-mfa.test.ts @@ -0,0 +1,221 @@ +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; +import { verifyAdmin, verifyAdminInBrowser, responseCookies } from "./support/admin-mfa"; +import { activeOrg, forBrowser, joinOrg } from "./support/session"; + +const decodeKey = Schema.decodeUnknownSync( + Schema.Struct({ id: Schema.String, value: Schema.String }), +); +const decodeUsers = Schema.decodeUnknownSync( + Schema.Struct({ + users: Schema.Array(Schema.Struct({ email: Schema.NullOr(Schema.String) })), + }), +); + +scenario( + "Organization MFA · protects settings without blocking workspace or backend credentials", + {}, + Effect.gen(function* () { + const target = yield* Target; + const locked = yield* target.newIdentity(); + const other = yield* target.newIdentity(); + const org = yield* activeOrg(target, locked); + const unlocked = yield* verifyAdmin(target.baseUrl, locked); + yield* Effect.promise(async () => { + const send = ( + headers: Readonly> | undefined, + method: string, + path: string, + body?: unknown, + ) => + fetch(new URL(path, target.baseUrl), { + method, + headers: { + ...headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + for (const path of [ + "/api/admin/users", + "/api/account/members", + "/api/account/roles", + "/api/policies", + "/api/account/api-keys", + "/api/account/org-api-keys", + ]) { + expect((await send(locked.headers, "GET", path)).status, path).toBe(200); + } + const settings = [ + { method: "PATCH", path: "/api/account/name", body: { name: "Verified workspace" } }, + { + method: "POST", + path: "/api/account/members/invite", + body: { email: "invited@example.com", roleSlug: "member" }, + }, + { method: "DELETE", path: "/api/account/members/membership_missing" }, + { + method: "PATCH", + path: "/api/account/members/membership_missing/role", + body: { roleSlug: "admin" }, + }, + { method: "GET", path: "/api/org/domains" }, + { method: "POST", path: "/api/org/domains/verify-link", body: {} }, + { method: "DELETE", path: "/api/org/domains/domain_missing" }, + { + method: "POST", + path: "/api/auth/delete-organization", + body: { confirmName: "Never delete this fixture" }, + }, + ]; + for (const action of settings) { + expect( + (await send(locked.headers, action.method, action.path, action.body)).status, + action.path, + ).toBe(403); + } + expect( + (await send(unlocked.headers, "PATCH", "/api/account/name", { name: "Verified workspace" })) + .status, + ).toBe(200); + expect((await send(unlocked.headers, "GET", "/api/org/domains")).status).toBe(200); + const proof = unlocked.headers?.cookie + ?.split("; ") + .find((pair) => pair.startsWith("__Host-executor-admin-mfa=")); + if (!proof) throw new Error("Verification returned no proof"); + expect( + ( + await send( + { ...other.headers, cookie: `${other.headers?.cookie}; ${proof}` }, + "PATCH", + "/api/account/name", + { name: "Cross-user attempt" }, + ) + ).status, + ).toBe(403); + + // Key management is on its own screen and remains available without MFA. + const minted = await send(locked.headers, "POST", "/api/account/org-api-keys", { + name: "Backend reader", + }); + expect(minted.status).toBe(200); + const key = decodeKey(await minted.json()); + try { + const lock = await send(unlocked.headers, "POST", "/api/auth/admin-mfa/lock", {}); + expect(lock.status).toBe(200); + const relocked = { + ...unlocked.headers, + cookie: responseCookies(unlocked.headers?.cookie ?? "", lock), + }; + expect( + (await send(relocked, "PATCH", "/api/account/name", { name: "Relocked attempt" })).status, + ).toBe(403); + const email = locked.credentials?.email; + if (!email) throw new Error("Test identity has no email"); + const bearer = { authorization: `Bearer ${key.value}` }; + for (const path of [ + "/api/admin/users", + `/api/admin/users/with-connections?email=${encodeURIComponent(email)}`, + ]) { + const response = await send(bearer, "GET", path); + expect(response.status, path).toBe(200); + expect(decodeUsers(await response.json()).users.map((user) => user.email)).toContain( + email, + ); + } + expect( + ( + await send( + { ...bearer, "x-executor-organization": org.id }, + "PATCH", + "/api/account/name", + { name: "Machine settings attempt" }, + ) + ).status, + ).toBe(401); + } finally { + expect( + (await send(locked.headers, "DELETE", `/api/account/org-api-keys/${key.id}`)).status, + ).toBe(200); + } + }); + }), +); + +scenario( + "Organization MFA · browser unlock is confined to organization settings", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const admin = yield* target.newIdentity(); + const org = yield* activeOrg(target, admin); + yield* browser.session(forBrowser(admin), async ({ page, step }) => { + await step("Open integrations without verifying", async () => { + await visit(page, `/${org.slug}/integrations/add/openapi`); + await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); + }); + await step( + "Organization settings asks for an authenticator before showing controls", + async () => { + await visit(page, `/${org.slug}/org`); + await page.getByRole("heading", { name: "Unlock organization settings" }).waitFor(); + expect(await page.getByLabel("Organization name", { exact: true }).count()).toBe(0); + expect(await page.getByRole("button", { name: "Delete", exact: true }).count()).toBe(0); + }, + ); + await step("Enroll and verify, then edit the organization name", async () => { + await verifyAdminInBrowser(page); + await page.getByLabel("Organization name", { exact: true }).fill("Verified organization"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.getByText("Organization name updated", { exact: true }).waitFor(); + }); + await step("Return to organization settings without another challenge", async () => { + await visit(page, `/${org.slug}/api-keys`); + await page.getByRole("heading", { name: "Personal keys", exact: true }).waitFor(); + await visit(page, `/${org.slug}/org`); + await page.getByLabel("Organization name", { exact: true }).waitFor(); + expect( + await page.getByRole("heading", { name: "Unlock organization settings" }).count(), + ).toBe(0); + }); + await step("Lock settings and keep API key management available", async () => { + await page.getByRole("button", { name: "Lock organization settings" }).click(); + await page.getByRole("heading", { name: "Unlock organization settings" }).waitFor(); + await visit(page, `/${org.slug}/api-keys`); + await page.getByRole("button", { name: "New org key" }).waitFor(); + }); + }); + }), +); + +scenario( + "Organization MFA · verification does not elevate a member to admin", + {}, + Effect.gen(function* () { + const target = yield* Target; + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + const verified = yield* verifyAdmin(target.baseUrl, member); + yield* Effect.promise(async () => { + const headers = { + ...verified.headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + }; + const domains = await fetch(new URL("/api/org/domains", target.baseUrl), { headers }); + expect(domains.status).toBe(200); + const rename = await fetch(new URL("/api/account/name", target.baseUrl), { + method: "PATCH", + headers, + body: JSON.stringify({ name: "Member cannot rename" }), + }); + expect(rename.status).toBe(403); + }); + }), +); diff --git a/e2e/cloud/spec-update-convergence.test.ts b/e2e/cloud/spec-update-convergence.test.ts index 0a9ca49647..7c893b9be6 100644 --- a/e2e/cloud/spec-update-convergence.test.ts +++ b/e2e/cloud/spec-update-convergence.test.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./support/admin-mfa"; // Cloud-only (needs real multi-user organizations): when one member refreshes // a shared integration's spec, a DIFFERENT member's OWN connection converges to // the new tool catalog on that member's next read — not just the editor's. @@ -150,7 +151,8 @@ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => Effect.gen(function* () { const adminSelector = admin.headers?.[ORG_SELECTOR_HEADER]; if (!adminSelector) throw new Error("admin identity carries no org selector header"); - const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { + const verifiedAdmin = yield* verifyAdmin(target.baseUrl, admin); + const inviteResponse = yield* postJson(target, "/api/account/members/invite", verifiedAdmin, { email: member.credentials?.email, }); const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; diff --git a/e2e/cloud/support/admin-mfa.ts b/e2e/cloud/support/admin-mfa.ts new file mode 100644 index 0000000000..2e28cb4248 --- /dev/null +++ b/e2e/cloud/support/admin-mfa.ts @@ -0,0 +1,126 @@ +import { Effect, Option, Schema } from "effect"; +import { TOTP } from "otpauth"; +import type { Page } from "playwright"; +import type { Identity } from "../../src/target"; + +// Each synthetic identity owns a test authenticator, reused for subsequent challenges. +const testAuthenticators = new Map(); + +const Setup = Schema.Struct({ kind: Schema.Literal("enroll"), secret: Schema.String }); +const decodeSetup = Schema.decodeUnknownOption(Setup); +const Challenge = Schema.Struct({ kind: Schema.Literal("challenge") }); +const decodeChallenge = Schema.decodeUnknownOption(Challenge); +const Verified = Schema.Struct({ verified: Schema.Literal(true) }); +const decodeVerified = Schema.decodeUnknownOption(Verified); +const decodeVerifiedState = Schema.decodeUnknownOption( + Schema.Struct({ state: Schema.Literal("verified") }), +); + +/** Apply response cookie rotations and deletions to a test client's cookie header. */ +export const responseCookies = (current: string, response: Response): string => { + const cookies = new Map(browserCookies(current).map(({ name, value }) => [name, value])); + for (const header of response.headers.getSetCookie()) { + const pair = header.split(";")[0]; + if (!pair) throw new Error("Empty response cookie"); + const separator = pair.indexOf("="); + if (separator < 1) throw new Error("Invalid response cookie"); + const name = pair.slice(0, separator); + if (/;\s*max-age=0(?:;|$)/i.test(header)) cookies.delete(name); + else cookies.set(name, pair.slice(separator + 1)); + } + return [...cookies].map(([name, value]) => `${name}=${value}`).join("; "); +}; + +/** Read all cookie pairs, including admin verification, into browser fixtures. */ +export const browserCookies = (cookie: string): NonNullable => + cookie + .split(";") + .map((pair) => pair.trim()) + .filter(Boolean) + .map((pair) => { + const separator = pair.indexOf("="); + if (separator < 1) throw new Error("Invalid test cookie"); + const name = pair.slice(0, separator); + return { + name, + value: pair.slice(separator + 1), + ...(name.startsWith("__Host-") ? { secure: true } : {}), + }; + }); + +/** Verify a test admin through the product, retaining the test authenticator for later sign-ins. */ +export const verifyAdmin = (baseUrl: string, identity: Identity): Effect.Effect => + Effect.promise(async () => { + const email = identity.credentials?.email; + if (!email) throw new Error("Test identity has no email"); + const headers = { + ...identity.headers, + origin: new URL(baseUrl).origin, + "content-type": "application/json", + }; + const status = await fetch(new URL("/api/auth/admin-mfa", baseUrl), { headers }); + if (status.ok && Option.isSome(decodeVerifiedState(await status.json()))) return identity; + const started = await fetch(new URL("/api/auth/admin-mfa/start", baseUrl), { + method: "POST", + headers, + body: "{}", + }); + if (!started.ok) throw new Error(`Admin enrollment failed (${started.status})`); + const raw: unknown = await started.json(); + const setup = Option.getOrNull(decodeSetup(raw)); + const secret = + setup?.secret ?? + (Option.isSome(decodeChallenge(raw)) ? testAuthenticators.get(email) : undefined); + if (!secret) throw new Error("Missing test authenticator"); + testAuthenticators.set(email, secret); + const pending = responseCookies(identity.headers?.cookie ?? "", started); + const verified = await fetch(new URL("/api/auth/admin-mfa/verify", baseUrl), { + method: "POST", + headers: { ...headers, cookie: pending }, + body: JSON.stringify({ code: new TOTP({ secret }).generate() }), + }); + if (!verified.ok || Option.isNone(decodeVerified(await verified.json()))) + throw new Error(`Admin verification failed (${verified.status})`); + const proof = verified.headers + .getSetCookie() + .find((cookie) => cookie.startsWith("__Host-executor-admin-mfa=")) + ?.split(";")[0]; + if (!proof) throw new Error("Admin verification set no proof cookie"); + const cookie = responseCookies(pending, verified); + return { + ...identity, + headers: { ...identity.headers, cookie }, + cookies: browserCookies(cookie), + }; + }); + +/** Complete the visible MFA prompt using enrollment or this test identity's authenticator. */ +export const verifyAdminInBrowser = async (page: Page, secret?: string): Promise => { + await page.getByRole("heading", { name: "Unlock organization settings" }).waitFor(); + const [started] = await Promise.all([ + page.waitForResponse((response) => response.url().endsWith("/api/auth/admin-mfa/start")), + page.getByRole("button", { name: "Continue", exact: true }).click(), + ]); + const raw: unknown = await started.json(); + const setup = Option.getOrNull(decodeSetup(raw)); + const key = setup?.secret ?? (Option.isSome(decodeChallenge(raw)) ? secret : undefined); + if (!started.ok() || !key) throw new Error("Could not open the test authenticator"); + await page.getByLabel("Six-digit code").fill(new TOTP({ secret: key }).generate()); + const [verified] = await Promise.all([ + page.waitForResponse((response) => response.url().endsWith("/api/auth/admin-mfa/verify")), + page.getByRole("button", { name: "Verify", exact: true }).click(), + ]); + if (!verified.ok()) throw new Error("Browser admin verification failed"); + await page + .getByRole("heading", { name: "Unlock organization settings" }) + .waitFor({ state: "detached" }); + // Successful verification reloads the document, so Chromium can discard that + // response body. Check the persisted session through the product instead. + const selector = new URL(page.url()).pathname.split("/")[1]; + if (!selector) throw new Error("Admin verification has no organization scope"); + const status = await page.request.get("/api/auth/admin-mfa", { + headers: { "x-executor-organization": selector }, + }); + if (!status.ok() || Option.isNone(decodeVerifiedState(await status.json()))) + throw new Error("The browser session is not verified"); +}; diff --git a/e2e/cloud/support/session.ts b/e2e/cloud/support/session.ts index cb8b0d6cf7..ef4c59ed18 100644 --- a/e2e/cloud/support/session.ts +++ b/e2e/cloud/support/session.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./admin-mfa"; // Cloud session + membership helpers shared by the scenarios that need MORE // than one identity in an org. // @@ -127,7 +128,8 @@ export const joinOrg = ( options: { readonly roleSlug?: string } = {}, ): Effect.Effect => Effect.gen(function* () { - const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { + const verifiedAdmin = yield* verifyAdmin(target.baseUrl, admin); + const inviteResponse = yield* postJson(target, "/api/account/members/invite", verifiedAdmin, { email: member.credentials?.email, ...(options.roleSlug === undefined ? {} : { roleSlug: options.roleSlug }), }); diff --git a/e2e/package.json b/e2e/package.json index 2671745d08..d1dd4ecf97 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/e2e", - "version": "0.0.49", + "version": "0.0.50", "private": true, "type": "module", "scripts": { @@ -23,7 +23,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.2", + "@executor-js/emulate": "0.14.3-mfa.0", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -48,6 +48,7 @@ "@vitejs/plugin-react": "catalog:", "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", + "otpauth": "^9.5.2", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/e2e/scenarios/connection-setup-ux.test.ts b/e2e/scenarios/connection-setup-ux.test.ts new file mode 100644 index 0000000000..fa87ea93d6 --- /dev/null +++ b/e2e/scenarios/connection-setup-ux.test.ts @@ -0,0 +1,290 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { connectEmulator } from "@executor-js/emulate"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; +import { variable } from "@executor-js/sdk/http-auth"; +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { hydrated, visit } from "../src/surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +// Each journey has its own real provider state, OAuth app, user and integration. +const connectionFixture = (registerClient: boolean) => + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`setup-${randomBytes(4).toString("hex")}`); + const app = OAuthClientSlug.make(`${slug}-app`); + const baseUrl = yield* createEmulatorInstance("slack", "connection-setup"); + const emulator = yield* Effect.promise(() => connectEmulator({ baseUrl })); + const credential = yield* Effect.promise(() => + emulator.credentials.mint({ + type: "oauth-authorization-code", + redirect_uris: [new URL("/api/oauth/callback", target.baseUrl).toString()], + }), + ); + const { + client_id: clientId, + client_secret: clientSecret, + authorization_url: authorizationUrl, + token_url: tokenUrl, + } = credential; + if (!clientId || !clientSecret || !authorizationUrl || !tokenUrl) { + return yield* Effect.die("Slack emulator did not mint an OAuth app"); + } + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const connections = yield* client.connections.list({ query: { integration: slug } }); + for (const connection of connections) { + yield* client.connections + .remove({ + params: { + owner: connection.owner, + integration: slug, + name: connection.name, + }, + }) + .pipe(Effect.ignore); + } + yield* client.oauth + .removeClient({ params: { slug: app }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }).pipe(Effect.ignore), + ); + yield* client.openapi.addSpec({ + payload: { + slug, + name: "Team chat", + baseUrl: "https://slack.com", + displayDomain: "slack.com", + spec: { + kind: "blob", + value: JSON.stringify({ + openapi: "3.0.3", + info: { title: "Team chat", version: "1" }, + // No API operations: only the isolated emulator receives OAuth traffic. + servers: [{ url: "https://slack.com" }], + paths: {}, + }), + }, + authenticationTemplate: [ + { + slug: "token", + type: "apiKey", + headers: { Authorization: ["Bearer ", variable("token")] }, + }, + { + slug: "oauth", + kind: "oauth2", + // The unconfigured case tests metadata only, without contacting a + // provider. A unique reserved host cannot match another test's app. + authorizationUrl: registerClient + ? authorizationUrl + : `https://${slug}.invalid/authorize`, + tokenUrl: registerClient ? tokenUrl : `https://${slug}.invalid/token`, + scopes: ["users:read"], + }, + ], + }, + }); + if (registerClient) + yield* client.oauth.createClient({ + payload: { + slug: app, + owner: "org", + grant: "authorization_code", + clientId, + clientSecret, + authorizationUrl, + tokenUrl, + originIntegration: slug, + }, + }); + return { target, browser, identity, client, slug, emulator }; + }); +const fixture = connectionFixture(true); + +scenario( + "Slack OAuth · provider consent saves a connection", + {}, + Effect.scoped( + Effect.gen(function* () { + const { browser, identity, slug, client, emulator } = yield* fixture; + yield* browser.session(identity, async ({ page, step }) => { + await step("Sign in with a provider account without naming the connection", async () => { + await visit(page, `/integrations/${slug}?addAccount=1`); + await page.getByRole("tab", { name: "OAuth2", exact: true }).click(); + const opened = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click(); + const popup = await opened; + await popup.waitForURL(/oauth\/v2\/authorize/); + // The hosted emulator renders a root-relative form action. Rebase only + // that provider transport onto this run's isolated instance. + await popup.route("https://emulators.dev/oauth/v2/authorize/callback", (route) => + route.continue({ url: `${emulator.baseUrl}/oauth/v2/authorize/callback` }), + ); + await popup.getByRole("button", { name: /admin/ }).click(); + await page + .getByRole("heading", { name: /Add connection/ }) + .waitFor({ state: "hidden", timeout: 30_000 }); + }); + }); + const connections = yield* client.connections.list({ query: { integration: slug } }); + expect(connections, "the completed callback persists the new account").toHaveLength(1); + expect(connections[0]?.name).toBeTruthy(); + const ledger = yield* Effect.promise(() => emulator.ledger.list()); + expect( + ledger.some((entry) => entry.method === "POST" && entry.path.includes("oauth.v2.access")), + "the real provider exchanged an authorization code", + ).toBe(true); + }), + ), +); + +scenario( + "Connection setup · without a matching client the API key stays the default", + {}, + Effect.scoped( + Effect.gen(function* () { + const { browser, identity, slug } = yield* connectionFixture(false); + yield* browser.session(identity, async ({ page, step }) => { + await step("Open an integration whose OAuth app has not been configured", async () => { + await visit(page, `/integrations/${slug}?addAccount=1`); + expect( + await page + .getByRole("tab", { name: "API key (Authorization)", exact: true }) + .getAttribute("aria-selected"), + "declaring OAuth without a usable client must not replace the key form", + ).toBe("true"); + await page.getByRole("tab", { name: "OAuth2", exact: true }).click(); + await page.getByRole("button", { name: "Register app", exact: true }).waitFor(); + }); + }); + }), + ), +); + +for (const { interaction, expectedOAuth, expectedKeys } of [ + { interaction: "untouched", expectedOAuth: "true", expectedKeys: [] }, + { interaction: "select API key", expectedOAuth: "false", expectedKeys: [""] }, + { + interaction: "enter API key", + expectedOAuth: "false", + expectedKeys: ["synthetic-key-in-progress"], + }, +] as const) { + scenario( + `Connection setup · client list arrives with the form ${interaction}`, + {}, + Effect.scoped( + Effect.gen(function* () { + const { browser, identity, slug } = yield* fixture; + yield* browser.session(identity, async ({ page, step }) => { + const started = Promise.withResolvers(); + const released = Promise.withResolvers(); + const completed = Promise.withResolvers(); + await page.route(/\/api\/oauth\/clients(?:\?|$)/, async (route) => { + const response = await route.fetch(); + started.resolve(); + await released.promise; + await route.fulfill({ response }); + completed.resolve(); + }); + try { + await step("Open the dialog while the real OAuth client list is held", async () => { + await page.goto(`/integrations/${slug}?addAccount=1`, { + waitUntil: "domcontentloaded", + }); + await hydrated(page); + await started.promise; + const keyTab = page.getByRole("tab", { + name: "API key (Authorization)", + exact: true, + }); + await keyTab.waitFor(); + expect(await keyTab.getAttribute("aria-selected")).toBe("true"); + }); + await step( + "Resolve client availability without replacing a user's choice", + async () => { + const keyTab = page.getByRole("tab", { + name: "API key (Authorization)", + exact: true, + }); + const keyInput = page.getByRole("textbox", { name: "Authorization", exact: true }); + if (interaction === "select API key") await keyTab.click(); + if (interaction === "enter API key") + await keyInput.fill("synthetic-key-in-progress"); + released.resolve(); + await completed.promise; + await page.waitForLoadState("networkidle"); + expect( + await page + .getByRole("tab", { name: "OAuth2", exact: true }) + .getAttribute("aria-selected"), + "only an untouched form should adopt the available OAuth client", + ).toBe(expectedOAuth); + expect( + await keyTab.getAttribute("aria-selected"), + "late data must preserve the chosen key form", + ).toBe(String(expectedOAuth === "false")); + expect( + await Promise.all((await keyInput.all()).map((input) => input.inputValue())), + "any key already entered must remain unchanged", + ).toEqual(expectedKeys); + }, + ); + } finally { + released.resolve(); + await page.unrouteAll({ behavior: "wait" }); + } + }); + }), + ), + ); +} + +scenario( + "Connection setup · a ready OAuth app is the default", + {}, + Effect.scoped( + Effect.gen(function* () { + const { browser, identity, slug } = yield* fixture; + yield* browser.session(identity, async ({ page, step }) => { + await step("Add a connection with both a token and a registered sign-in app", async () => { + await visit(page, `/integrations/${slug}?addAccount=1`); + await page.getByRole("tab", { name: "OAuth2", exact: true }).waitFor(); + expect( + await page + .getByRole("tab", { name: "OAuth2", exact: true }) + .getAttribute("aria-selected"), + "sign-in is selected without first switching away from API token", + ).toBe("true"); + }); + await step("Choose an API key instead of browser sign-in", async () => { + const tokenTab = page.getByRole("tab", { name: "API key (Authorization)", exact: true }); + await tokenTab.click(); + expect(await tokenTab.getAttribute("aria-selected")).toBe("true"); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.getByRole("heading", { name: /Add connection/ }).waitFor({ state: "hidden" }); + }); + await step("Open a new connection on browser sign-in again", async () => { + await page.getByRole("button", { name: "Add connection", exact: true }).click(); + expect( + await page + .getByRole("tab", { name: "OAuth2", exact: true }) + .getAttribute("aria-selected"), + ).toBe("true"); + }); + }); + }), + ), +); diff --git a/e2e/scenarios/health-probe-churn.test.ts b/e2e/scenarios/health-probe-churn.test.ts new file mode 100644 index 0000000000..53cd64ee8d --- /dev/null +++ b/e2e/scenarios/health-probe-churn.test.ts @@ -0,0 +1,297 @@ +// Cross-target (browser): the health-probe churn loop seen in production. +// +// Production symptom (2026-09-18): one signed-in browser with many +// non-healthy or never-checked connections spread across many integrations +// sent hundreds of `/api/connections/.../health` POSTs and `/api/connections` +// GETs per minute for as long as the dashboard stayed open. Most were +// interrupted client-side before completing (the shared health mutation atom +// cancels the previous in-flight call), but hundreds per minute still reached +// the server and the upstreams. The server annotated every verdict as +// unchanged, so the loop lives in the client. +// +// The existing verdict scenario pins "one broken connection probes exactly +// once per surface". This one reproduces the production SHAPE: many +// connections, several integrations, a mix of verdicts (`unknown` from a +// connection with no probe configured, `degraded` from an upstream that +// answers 401), and the page journey a user actually makes (integrations +// list → integration page → integrations list). It then watches a quiet +// window. A settled page must stop asking. A count that keeps climbing is the +// loop. +// +// Skips on targets with no browser surface. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { HttpApiClient } from "effect/unstable/httpapi"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +type Client = HttpApiClient.ForApi; + +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +/** Integrations whose connection has a probe configured and an upstream that + * rejects the key: the probe persists `degraded`. */ +const DEGRADED_COUNT = 4; +/** Integrations whose connection has NO probe configured: every automatic + * check answers `unknown` with a fresh `checkedAt`. */ +const UNKNOWN_COUNT = 4; + +/** How long a settled page is watched for further probes. Production cycled + * every 0.5-1s, so a loop shows up well inside this. */ +const QUIET_WINDOW_MS = 10_000; + +const unique = (prefix: string) => `${prefix}${randomBytes(3).toString("hex")}`; + +/** Upstream on 127.0.0.1 whose `GET /me` rejects every key. */ +const serveRejectingUpstream = () => + Effect.acquireRelease( + Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => { + const server = createServer((request, response) => { + if (request.method === "GET" && (request.url ?? "").startsWith("/me")) { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "invalid_token" })); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +const identitySpec = (baseUrl: string, title: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title, version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/me": { + get: { + operationId: "getMe", + summary: "The current account", + responses: { + "200": { + description: "The authenticated account", + content: { + "application/json": { + schema: { type: "object", properties: { email: { type: "string" } } }, + }, + }, + }, + }, + }, + }, + }, + }); + +/** One OpenAPI integration with one saved org connection. With `probe`, the + * identity GET is configured as the health check. */ +const seedIntegration = ( + client: Client, + upstreamUrl: string, + options: { readonly prefix: string; readonly probe: boolean }, +) => + Effect.gen(function* () { + const slug = IntegrationSlug.make(unique(options.prefix)); + const name = ConnectionName.make(`${options.prefix}conn`); + + yield* Effect.addFinalizer(() => + Effect.all( + [ + client.connections + .remove({ params: { owner: "org", integration: slug, name } }) + .pipe(Effect.ignore), + client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ], + { discard: true }, + ), + ); + + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: identitySpec(upstreamUrl, `Churn ${slug}`) }, + slug, + baseUrl: upstreamUrl, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + + if (options.probe) { + const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); + const getMe = candidates.find((candidate) => candidate.method === "get"); + if (!getMe) return yield* Effect.die("identity spec exposed no GET candidate"); + yield* client.integrations.healthCheckSet({ + params: { slug }, + payload: { spec: { operation: getMe.operation, identityField: "email" } }, + }); + } + + yield* client.connections.create({ + payload: { owner: "org", name, integration: slug, template: TEMPLATE, value: "bad-key" }, + }); + + return { slug, name }; + }); + +scenario( + "Health checks (UI) · many broken connections across many integrations settle instead of looping", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const upstream = yield* serveRejectingUpstream(); + + const degraded = yield* Effect.all( + Array.from({ length: DEGRADED_COUNT }, () => + seedIntegration(client, upstream.url, { prefix: "churndeg", probe: true }), + ), + { concurrency: 2 }, + ); + const unknown = yield* Effect.all( + Array.from({ length: UNKNOWN_COUNT }, () => + seedIntegration(client, upstream.url, { prefix: "churnunk", probe: false }), + ), + { concurrency: 2 }, + ); + const seeded = [...degraded, ...unknown]; + const connectionCount = seeded.length; + + // Persist a verdict on every connection BEFORE the browser opens, the + // way a returning user finds them: `degraded` on the probed ones, + // `unknown` on the rest. The list then renders each row with a + // persisted verdict, and every automatic revalidation compares its + // result against one. + for (const { slug, name } of seeded) { + yield* client.connections.checkHealth({ + params: { owner: "org", integration: slug, name }, + query: {}, + }); + } + + yield* browser.session(identity, async ({ page, step }) => { + // The client's wire contract, observed from outside: every health POST + // and every connections-list GET the app sends, plus how many the + // browser reports as failed (an aborted request shows up here as a + // failure, which is how the interrupted mutation atom looks on the wire). + const health: string[] = []; + const lists: string[] = []; + let aborted = 0; + // Only THIS scenario's connections count. Targets that share one org + // across scenarios (selfhost) can carry rows another scenario left + // behind, and those revalidate too; they are not ours to bound. + const seededSlugs = new Set(seeded.map(({ slug }) => String(slug))); + const isHealth = (method: string, url: string) => + method === "POST" && + url.includes("/health") && + [...seededSlugs].some((slug) => url.includes(`/api/connections/org/${slug}/`)); + const isList = (method: string, url: string) => + method === "GET" && /\/api\/connections(\?|$)/.test(url); + page.on("request", (request) => { + const url = request.url(); + if (isHealth(request.method(), url)) health.push(url); + else if (isList(request.method(), url)) lists.push(url); + }); + page.on("requestfailed", (request) => { + const url = request.url(); + if (isHealth(request.method(), url) || isList(request.method(), url)) aborted += 1; + }); + + const snapshot = (label: string) => { + const line = `[churn] ${label}: health=${String(health.length)} lists=${String(lists.length)} aborted=${String(aborted)}`; + console.log(line); + return { health: health.length, lists: lists.length, aborted }; + }; + + await step("Open the integrations list with every broken connection on it", async () => { + await visit(page, "/"); + for (const { slug } of seeded) { + await page + .getByRole("link", { name: new RegExp(slug, "i") }) + .first() + .waitFor({ + timeout: 30_000, + }); + } + // Let the automatic revalidation land for every row. + await page.waitForTimeout(3_000); + }); + const afterList = snapshot("after integrations list"); + + await step("Open one integration page, then return to the list", async () => { + await visit(page, `/integrations/${degraded[0]!.slug}`); + await page.waitForTimeout(2_000); + await visit(page, "/"); + await page.waitForTimeout(3_000); + }); + const afterJourney = snapshot("after journey"); + + // The quiet window: nothing changed on the page, nothing changed on + // the server, so nothing more should be asked. + await step("Leave the settled list open and watch the wire", async () => { + await page.waitForTimeout(QUIET_WINDOW_MS); + }); + const afterQuiet = snapshot("after quiet window"); + + const quietHealth = afterQuiet.health - afterJourney.health; + const quietLists = afterQuiet.lists - afterJourney.lists; + console.log( + `[churn] quiet window (${String(QUIET_WINDOW_MS)}ms): +${String(quietHealth)} health, +${String(quietLists)} list fetches over ${String(connectionCount)} connections`, + ); + + // One list mount revalidates each non-healthy connection once, and + // reads the connections list once per owner (plus the unscoped read). + // A probe count above the connection count means rows are being + // re-probed; a list count above three means verdicts are being + // treated as changes and refetching the list. + expect( + afterList.health, + `the first list mount probes each broken connection at most once (sent ${String(afterList.health)} for ${String(connectionCount)} connections)`, + ).toBeLessThanOrEqual(connectionCount); + expect( + afterList.lists, + `the first list mount reads the connections list at most once per owner (sent ${String(afterList.lists)})`, + ).toBeLessThanOrEqual(3); + + // THE production symptom: a settled page keeps asking. Zero is the + // contract; anything else is the loop. + expect(quietHealth, "a settled integrations list sends no further health probes").toBe(0); + expect( + quietLists, + "a settled integrations list does not refetch the connections list", + ).toBe(0); + }); + }), + ), +); diff --git a/e2e/scenarios/policies-ui.test.ts b/e2e/scenarios/policies-ui.test.ts index ad45864429..00509252f5 100644 --- a/e2e/scenarios/policies-ui.test.ts +++ b/e2e/scenarios/policies-ui.test.ts @@ -4,18 +4,23 @@ // the category (group) row menu writes a subtree rule. The product promises // under test: // -// 1. Both menus surface the REAL stored pattern (connection-wildcarded -// `integration.*.*.tool`) before anything is written. +// 1. Both menus surface the REAL stored pattern (pinned to the account the +// row sits under, `integration...tool`) before +// anything is written. // 2. A leaf rule and a category rule coexist: the more specific leaf rule // keeps precedence over the later category rule, which covers the rest // of its group. -// 3. Rules are connection-agnostic: set from one account's section, they -// govern the other account's rows too, and the menu there shows the -// active rule with a Clear option. -// 4. The tool detail header's policy badge is the same authoring surface: +// 3. Rules are account-scoped: set from one account's section, they leave +// the other account's rows untouched. Two connections of one +// integration are different credentials (a bot token and a user token), +// so blocking a tool on one must not block it on the other. +// 4. The account header has its own menu that rules the whole connection +// (`integration...*`), recognizes its rule, and +// clears it. +// 5. The tool detail header's policy badge is the same authoring surface: // it writes the same stored pattern, recognizes its own rule afterward // (the Clear affordance), and Clear really removes the rule. -// 5. The rules materialize as manageable rows on /policies and persist +// 6. The rules materialize as manageable rows on /policies and persist // server-side with exactly the owner/pattern/action the UI promised. import { randomBytes } from "node:crypto"; @@ -83,11 +88,12 @@ scenario( const beta = ConnectionName.make(`beta${suffix}`); const accounts = [alpha, beta] as const; - // The UI hides owner/connection segments; a rule authored on a node is - // stored connection-wildcarded so it spans every account. - const leafPattern = `${integration}.*.*.records.create`; - const categoryPattern = `${integration}.*.*.records.*`; - const listLeafPattern = `${integration}.*.*.records.list`; + // The UI hides owner/connection segments in the row labels, but a rule + // authored under an account section is stored pinned to that account. + const leafPattern = `${integration}.org.${alpha}.records.create`; + const categoryPattern = `${integration}.org.${alpha}.records.*`; + const listLeafPattern = `${integration}.org.${alpha}.records.list`; + const betaAccountPattern = `${integration}.org.${beta}.*`; // Selfhost scenarios share one workspace — remove everything this one // made (policies, connections, the integration) even on failure. @@ -159,6 +165,14 @@ scenario( .getByRole("button") .filter({ hasText: leaf }) .getByLabel(label, { exact: true }); + // Wait until a leaf's indicator with this label is gone (after a clear). + const expectNoIndicator = async (connection: string, leaf: string, label: string) => { + await expect + .poll(() => leafIndicator(connection, leaf, label).count(), { + message: `${connection} ${leaf} still shows "${label}"`, + }) + .toBe(0); + }; const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" }); await step("Open the integration's Tools tab", async () => { @@ -254,25 +268,58 @@ scenario( }, ); - await step("The same rules govern the second account's rows", async () => { + await step("The second account's rows are untouched", async () => { await closedGroup(beta, integration).click(); await closedGroup(beta, "records").click(); - await leafIndicator(beta, "create", `Blocked (matched ${leafPattern})`).waitFor(); - await leafIndicator( - beta, - "list", - `Require approval (matched ${categoryPattern})`, - ).waitFor(); + await sectionFor(beta).getByRole("button").filter({ hasText: "create" }).waitFor(); + expect( + await leafIndicator(beta, "create", `Blocked (matched ${leafPattern})`).count(), + "a rule set under one account does not block the same tool on another", + ).toBe(0); + expect( + await leafIndicator( + beta, + "list", + `Require approval (matched ${categoryPattern})`, + ).count(), + "a category rule set under one account does not reach another account", + ).toBe(0); }); await step("Reopening the menu offers to clear the active rule", async () => { - await policyMenuFor(beta, `${integration}.records.create`).click(); + await policyMenuFor(alpha, `${integration}.records.create`).click(); await page.getByRole("menuitem", { name: "Clear" }).waitFor(); await page.keyboard.press("Escape"); }); + await step("The account header blocks the whole second connection", async () => { + const headerMenu = sectionFor(beta).getByRole("button", { + name: `Set policy for ${integration} / ${beta}`, + exact: true, + }); + await headerMenu.click(); + // The header menu is headed by the whole-account pattern it will store. + await page.getByText(betaAccountPattern, { exact: true }).waitFor(); + await page.getByRole("menuitem", { name: "Block" }).click(); + await leafIndicator(beta, "create", `Blocked (matched ${betaAccountPattern})`).waitFor(); + await leafIndicator(beta, "list", `Blocked (matched ${betaAccountPattern})`).waitFor(); + // The first account is not affected by the second account's rule. + await leafIndicator(alpha, "create", `Blocked (matched ${leafPattern})`).waitFor(); + }); + + await step("The account header recognizes its rule and Clear removes it", async () => { + const headerMenu = sectionFor(beta).getByRole("button", { + name: `Set policy for ${integration} / ${beta}`, + exact: true, + }); + await headerMenu.click(); + await page.getByRole("menuitem", { name: "Clear" }).click(); + await sectionFor(beta).getByRole("button").filter({ hasText: "create" }).waitFor(); + await expectNoIndicator(beta, "create", `Blocked (matched ${betaAccountPattern})`); + }); + await step("Open the tool detail for records.list", async () => { - await sectionFor(beta).getByRole("button").filter({ hasText: "list" }).click(); + await sectionFor(alpha).getByRole("button").filter({ hasText: "list" }).click(); // The header badge reflects the inherited category rule. await page.getByRole("button", { name: `Matched policy: ${categoryPattern}` }).waitFor(); }); diff --git a/e2e/selfhost/integration-creation-permissions.test.ts b/e2e/selfhost/integration-creation-permissions.test.ts new file mode 100644 index 0000000000..0ddd87ebe8 --- /dev/null +++ b/e2e/selfhost/integration-creation-permissions.test.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect"; +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; +import { integrationCreationPermissions } from "../src/integration-creation-permissions"; +import { createInvitedIdentity } from "../targets/selfhost"; + +scenario( + "Integration creation · self-host members see admin guidance and owners can add", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const admin = yield* target.newIdentity(); + const member = yield* Effect.promise(() => + createInvitedIdentity(target.baseUrl, admin, { + role: "member", + emailPrefix: "integration-permissions", + }), + ); + yield* integrationCreationPermissions(admin, member); + }), +); diff --git a/e2e/setup/selfhost-docker.boot.ts b/e2e/setup/selfhost-docker.boot.ts index 67f49d0fbf..d8bcc27f92 100644 --- a/e2e/setup/selfhost-docker.boot.ts +++ b/e2e/setup/selfhost-docker.boot.ts @@ -108,6 +108,11 @@ export const runSelfhostContainer = async (options: RunContainerOptions): Promis // test servers and points the instance at them. "-e", "EXECUTOR_ALLOW_LOCAL_NETWORK=true", + // The production image runs Better Auth's rate limiter. It sees no proxy + // header here, so it pools every caller into one bucket of three sign-ins + // per ten seconds, and this suite signs in from 100+ files at once. + "-e", + "EXECUTOR_DISABLE_AUTH_RATE_LIMIT=true", options.image, ]; log(options.logFile, `docker ${args.join(" ")}`); diff --git a/e2e/src/integration-creation-permissions.ts b/e2e/src/integration-creation-permissions.ts new file mode 100644 index 0000000000..cce298ac9f --- /dev/null +++ b/e2e/src/integration-creation-permissions.ts @@ -0,0 +1,176 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { Api, Browser } from "./services"; +import type { Identity } from "./target"; +import { visit } from "./surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +/** Exercise integration creation and member restrictions through the shared console. */ +export const integrationCreationPermissions = (admin: Identity, member: Identity) => + Effect.gen(function* () { + const browser = yield* Browser; + const { client } = yield* Api; + const adminClient = yield* client(api, admin); + const title = `Permissions API ${randomBytes(4).toString("hex")}`; + const slug = IntegrationSlug.make(title.toLowerCase().replaceAll(" ", "_")); + const spec = JSON.stringify({ + openapi: "3.0.3", + info: { title, version: "1.0.0" }, + servers: [{ url: "https://api.example.com" }], + paths: {}, + components: { + securitySchemes: { apiKey: { type: "apiKey", in: "header", name: "X-API-Key" } }, + }, + security: [{ apiKey: [] }], + }); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* browser.session(admin, async ({ page, step }) => { + await step("Admin opens the integration catalog", async () => { + await visit(page, "/"); + await page.getByRole("button", { name: "Browse integrations", exact: true }).waitFor(); + await page.keyboard.press("ControlOrMeta+k"); + await page.getByRole("option", { name: /^Add OpenAPI/ }).waitFor(); + await page.keyboard.press("Escape"); + await page.getByRole("link", { name: "Add integration", exact: true }).click(); + await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor(); + await page + .getByRole("textbox", { name: "Search integrations, or paste a URL" }) + .waitFor(); + }); + await step("Admin creates an integration from the setup form", async () => { + await visit(page, "/integrations/add/openapi"); + await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec); + await page.getByRole("button", { name: "Add integration", exact: true }).click(); + await page.waitForURL((url) => url.pathname.endsWith(`/integrations/${slug}`), { + timeout: 30_000, + }); + await page.getByRole("button", { name: "Edit", exact: true }).waitFor(); + await page.getByRole("button", { name: "Delete", exact: true }).waitFor(); + }); + }); + expect(yield* adminClient.integrations.get({ params: { slug } })).toMatchObject({ + name: title, + }); + + yield* browser.session(member, async ({ page, step }) => { + await step( + "Member sees disabled creation controls with an admin explanation", + async () => { + await visit(page, "/"); + await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor(); + await page.getByTestId(`integration-entry-${slug}`).waitFor(); + const add = page.getByRole("button", { name: "Add integration", exact: true }); + await add.waitFor(); + expect(await add.isDisabled()).toBe(true); + expect( + await page + .getByRole("button", { name: "Browse integrations", exact: true }) + .isDisabled(), + ).toBe(true); + const hint = page + .getByRole("group", { name: "Requires a workspace admin" }) + .filter({ has: add }); + await hint.hover(); + await page.getByRole("tooltip", { name: "Requires a workspace admin" }).waitFor(); + await hint.focus(); + const before = page.url(); + await page.keyboard.press("Enter"); + expect(page.url()).toBe(before); + }, + ); + await step( + "Member sees disabled add commands and can still find existing integrations", + async () => { + await page.keyboard.press("ControlOrMeta+k"); + const palette = page.getByRole("dialog"); + await palette.getByRole("option", { name: new RegExp(title) }).waitFor(); + const addCommand = palette.getByRole("option", { name: /^Add OpenAPI/ }); + await addCommand.waitFor(); + expect(await addCommand.getAttribute("aria-disabled")).toBe("true"); + expect(await addCommand.textContent()).toContain("Admin only"); + await page.keyboard.press("Escape"); + }, + ); + await step("Member sees disabled Edit and Delete actions", async () => { + await page.getByTestId(`integration-entry-${slug}`).click(); + await page.getByRole("button", { name: "Add connection", exact: true }).waitFor(); + for (const name of ["Edit", "Delete"]) { + const action = page.getByRole("button", { name, exact: true }); + await action.waitFor(); + expect(await action.isDisabled()).toBe(true); + } + }); + await step("Member sees tool policies without a way to change them", async () => { + // Policies on the Tools page are workspace rules the server refuses + // for members, so the row menus and the detail badge menu stay off. + await visit(page, "/tools"); + await page.getByRole("button").filter({ hasText: "executor" }).first().waitFor(); + expect( + await page.getByRole("button", { name: /^Set policy/ }).count(), + "members get no policy menus on tool rows", + ).toBe(0); + await visit(page, `/integrations/${slug}`); + await page.getByRole("button", { name: "Add connection", exact: true }).waitFor(); + }); + await step("Member can still add a personal connection", async () => { + await page.getByRole("button", { name: "Add connection", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await dialog.waitFor(); + expect(await dialog.getByText("Workspace", { exact: true }).count()).toBe(0); + }); + await step("Member browses the catalog with disabled Add buttons", async () => { + await visit(page, "/integrations/browse"); + await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor(); + await page + .getByText("Requires a workspace admin to add integrations.", { exact: true }) + .waitFor(); + const addButtons = page.getByRole("button", { name: /^Add / }); + await addButtons.first().waitFor(); + for (const button of await addButtons.all()) + expect(await button.isDisabled()).toBe(true); + const scratch = page.getByRole("button", { + name: "New OpenAPI integration from scratch", + exact: true, + }); + expect(await scratch.isDisabled()).toBe(true); + const view = page.getByRole("link", { name: `View ${title}`, exact: true }); + await view.waitFor(); + expect(await view.isEnabled()).toBe(true); + }); + await step("Member cannot add a URL with the button or Enter key", async () => { + const input = page.getByRole("textbox", { + name: "Search integrations, or paste a URL", + }); + await input.fill("https://api.example.com/openapi.json"); + expect( + await page.getByRole("button", { name: "Add this URL", exact: true }).isDisabled(), + ).toBe(true); + const before = page.url(); + await input.press("Enter"); + expect(page.url()).toBe(before); + }); + for (const path of ["/integrations/add/openapi", "/integrations/add/mcp"]) { + await step(`Member follows ${path} and sees the admin explanation`, async () => { + await visit(page, path); + await page.getByRole("heading", { name: "An admin must add integrations" }).waitFor(); + expect(await page.getByRole("textbox").count()).toBe(0); + expect(await page.getByRole("button", { name: /^Add/ }).count()).toBe(0); + }); + } + await step("Member returns to their existing integrations", async () => { + await page.getByRole("link", { name: "Back to integrations" }).click(); + await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor(); + }); + }); + }), + adminClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ); + }); diff --git a/examples/all-plugins/CHANGELOG.md b/examples/all-plugins/CHANGELOG.md index e980998560..cf669b4ade 100644 --- a/examples/all-plugins/CHANGELOG.md +++ b/examples/all-plugins/CHANGELOG.md @@ -1,5 +1,19 @@ # @executor-js/example-all-plugins +## 0.0.71 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/plugin-file-secrets@1.6.10 + - @executor-js/plugin-graphql@1.6.10 + - @executor-js/plugin-keychain@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-onepassword@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/plugin-workos-vault@0.0.2 + ## 0.0.70 ### Patch Changes diff --git a/examples/all-plugins/package.json b/examples/all-plugins/package.json index 4022ceddd2..6adceba1ff 100644 --- a/examples/all-plugins/package.json +++ b/examples/all-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/example-all-plugins", - "version": "0.0.70", + "version": "0.0.71", "private": true, "type": "module", "scripts": { diff --git a/examples/docs-sdk-quickstart/CHANGELOG.md b/examples/docs-sdk-quickstart/CHANGELOG.md index 058f39683f..a7d9111d44 100644 --- a/examples/docs-sdk-quickstart/CHANGELOG.md +++ b/examples/docs-sdk-quickstart/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/example-docs-sdk-quickstart +## 0.0.56 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + ## 0.0.55 ### Patch Changes diff --git a/examples/docs-sdk-quickstart/package.json b/examples/docs-sdk-quickstart/package.json index da8f8f480d..5828cfdb82 100644 --- a/examples/docs-sdk-quickstart/package.json +++ b/examples/docs-sdk-quickstart/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/example-docs-sdk-quickstart", - "version": "0.0.55", + "version": "0.0.56", "private": true, "type": "module", "scripts": { diff --git a/packages/core/analytics/CHANGELOG.md b/packages/core/analytics/CHANGELOG.md index 4827f67b1f..ee97c48fab 100644 --- a/packages/core/analytics/CHANGELOG.md +++ b/packages/core/analytics/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/analytics +## 0.1.17 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/execution@1.6.10 + ## 0.1.16 ### Patch Changes diff --git a/packages/core/analytics/package.json b/packages/core/analytics/package.json index 9b8baf18eb..1862e0851d 100644 --- a/packages/core/analytics/package.json +++ b/packages/core/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/analytics", - "version": "0.1.16", + "version": "0.1.17", "private": true, "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/analytics", "bugs": { diff --git a/packages/core/api/CHANGELOG.md b/packages/core/api/CHANGELOG.md index 7910d9a3cf..ea1036bfd3 100644 --- a/packages/core/api/CHANGELOG.md +++ b/packages/core/api/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/api +## 1.4.73 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/execution@1.6.10 + - @executor-js/host-mcp@1.4.4 + ## 1.4.72 ### Patch Changes diff --git a/packages/core/api/package.json b/packages/core/api/package.json index 8205bb886e..31c1beca31 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/api", - "version": "1.4.72", + "version": "1.4.73", "private": true, "type": "module", "exports": { diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 5d75cd8308..28dfc4cda7 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -133,6 +133,12 @@ export interface HostConfigShape { * operator knob. */ readonly toolsSyncTtlMs?: number | null; + /** + * Forwarded verbatim to `ExecutorConfig.toolsSyncConcurrency`: how many + * background catalog rebuilds one executor runs at the same time. Hosts whose + * instances have a small memory limit set it low. Omit for the SDK default. + */ + readonly toolsSyncConcurrency?: number; /** * Forwarded to `ExecutorConfig.waitUntil`: the host's keep-alive * for background work that outlives a request (stale tool-catalog rebuilds @@ -341,6 +347,9 @@ export const makeScopedExecutor = < fetch: hostedFetch, onIntegrationChange: config.onIntegrationChange, ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}), + ...(config.toolsSyncConcurrency !== undefined + ? { toolsSyncConcurrency: config.toolsSyncConcurrency } + : {}), ...(waitUntil !== undefined ? { waitUntil } : {}), onElicitation: "accept-all", ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), diff --git a/packages/core/cli/CHANGELOG.md b/packages/core/cli/CHANGELOG.md index adb1299ba0..e1812914a5 100644 --- a/packages/core/cli/CHANGELOG.md +++ b/packages/core/cli/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/cli +## 0.2.60 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 0.2.59 ### Patch Changes diff --git a/packages/core/cli/package.json b/packages/core/cli/package.json index f5477f210b..ab83e21e04 100644 --- a/packages/core/cli/package.json +++ b/packages/core/cli/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cli", - "version": "0.2.59", + "version": "0.2.60", "description": "CLI for the executor SDK — schema generation, migrations", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/cli", "bugs": { diff --git a/packages/core/config/CHANGELOG.md b/packages/core/config/CHANGELOG.md index 20701aaeb9..46b8e59a35 100644 --- a/packages/core/config/CHANGELOG.md +++ b/packages/core/config/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/config +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/core/config/package.json b/packages/core/config/package.json index dfd10bd38a..b128eebddf 100644 --- a/packages/core/config/package.json +++ b/packages/core/config/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/config", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/config", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/execution/CHANGELOG.md b/packages/core/execution/CHANGELOG.md index cbfa89cdb7..cd3eee3c46 100644 --- a/packages/core/execution/CHANGELOG.md +++ b/packages/core/execution/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/execution +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/codemode-core@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/core/execution/package.json b/packages/core/execution/package.json index 0891004b15..edefd98597 100644 --- a/packages/core/execution/package.json +++ b/packages/core/execution/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/execution", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/execution", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/sdk/CHANGELOG.md b/packages/core/sdk/CHANGELOG.md index 4a1f1d2334..6e05d81e28 100644 --- a/packages/core/sdk/CHANGELOG.md +++ b/packages/core/sdk/CHANGELOG.md @@ -1,5 +1,7 @@ # @executor-js/sdk +## 1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/core/sdk/package.json b/packages/core/sdk/package.json index 9412d9d4da..bf4374d6e7 100644 --- a/packages/core/sdk/package.json +++ b/packages/core/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/sdk", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/sdk", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/sdk/src/connections.test.ts b/packages/core/sdk/src/connections.test.ts index 3c4f73cd7d..b014813e38 100644 --- a/packages/core/sdk/src/connections.test.ts +++ b/packages/core/sdk/src/connections.test.ts @@ -10,6 +10,7 @@ import { Option, Predicate, Result, + Schedule, Schema, Tracer, } from "effect"; @@ -1912,6 +1913,135 @@ describe("tool catalog sync safety", () => { ), ); + // Live clock: the poll below waits on a detached rebuild fiber, not on the + // test clock. + it.live("a time-expired catalog answers from persisted rows and rebuilds in the background", () => + Effect.scoped( + Effect.gen(function* () { + const listingStarted = yield* Deferred.make(); + const releaseListing = yield* Deferred.make(); + let resolutions = 0; + const remotePlugin = definePlugin(() => ({ + id: "remote" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + remoteToolCatalog: true, + resolveTools: () => + Effect.gen(function* () { + resolutions += 1; + if (resolutions === 1) { + return { tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }; + } + yield* Deferred.succeed(listingStarted, undefined); + yield* Deferred.await(releaseListing); + return { + tools: [ + { name: ToolName.make("deploy"), description: "deploy" }, + { name: ToolName.make("list"), description: "list" }, + ], + }; + }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), + }), + }))(); + // TTL 0: every catalog is time-expired on every read. + const config = { + ...makeTestConfig({ plugins: [remotePlugin] as const }), + toolsSyncTtlMs: 0, + }; + const executor = yield* createExecutor(config); + yield* executor.remote.seed(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", + }); + + // Let the clock move past the stamp `create` wrote, so the catalog is + // older than the zero TTL on the read below. + yield* Effect.sleep("5 millis"); + + // The upstream listing is held open. A read that waited on it would + // pay the full grace budget; this one must answer at once from the + // persisted catalog. + const startedAt = Date.now(); + const stale = yield* executor.tools.list({ integration: INTEG }); + expect(Date.now() - startedAt).toBeLessThan(1000); + expect(stale.map((tool) => String(tool.name))).toEqual(["deploy"]); + yield* Deferred.await(listingStarted); + + // Once the background rebuild lands, a later read observes it. + yield* Deferred.succeed(releaseListing, undefined); + const converged = yield* executor.tools.list({ integration: INTEG }).pipe( + Effect.map((tools) => tools.map((tool) => String(tool.name)).sort()), + Effect.repeat({ + until: (names) => names.length === 2, + schedule: Schedule.spaced("10 millis"), + }), + Effect.timeout("5 seconds"), + ); + expect(converged).toEqual(["deploy", "list"]); + }), + ), + ); + + it.effect("a stale-marked catalog still gates the read within the grace budget", () => + Effect.scoped( + Effect.gen(function* () { + let resolutions = 0; + const remotePlugin = definePlugin(() => ({ + id: "remote" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + remoteToolCatalog: true, + resolveTools: () => + Effect.sync(() => { + resolutions += 1; + return { + tools: + resolutions === 1 + ? [{ name: ToolName.make("deploy"), description: "deploy" }] + : [ + { name: ToolName.make("deploy"), description: "deploy" }, + { name: ToolName.make("list"), description: "list" }, + ], + }; + }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), + }), + }))(); + const config = makeTestConfig({ plugins: [remotePlugin] as const }); + const executor = yield* createExecutor(config); + yield* executor.remote.seed(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", + }); + // Stale-marked (an upstream said the catalog changed): the very next + // read reflects the rebuild. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "main")), + set: { tools_synced_at: null }, + }), + ); + const tools = yield* executor.tools.list({ integration: INTEG }); + expect(tools.map((tool) => String(tool.name)).sort()).toEqual(["deploy", "list"]); + }), + ), + ); + it.effect( "a sync that started and never finished is not retried on every read until its backoff passes", () => @@ -1984,6 +2114,145 @@ describe("tool catalog sync safety", () => { ), ); + it.live( + "a read over many stale catalogs rebuilds them one at a time and answers without waiting for all of them", + () => + Effect.scoped( + Effect.gen(function* () { + const names = ["a", "b", "c", "d", "e"]; + let parked = false; + const release = yield* Deferred.make(); + let inFlight = 0; + let maxInFlight = 0; + const rebuilt: string[] = []; + const heavyPlugin = definePlugin(() => ({ + id: "heavy" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + resolveTools: ({ connection }) => + Effect.gen(function* () { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + if (parked) yield* Deferred.await(release); + inFlight -= 1; + if (parked) rebuilt.push(String(connection.name)); + return { tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }; + }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), + }), + }))(); + const config = makeTestConfig({ plugins: [heavyPlugin] as const }); + const executor = yield* createExecutor({ + ...config, + toolsSyncConcurrency: 1, + toolsSyncGraceMs: 50, + }); + yield* executor.heavy.seed(); + for (const name of names) { + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make(name), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", + }); + } + parked = true; + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("integration", "=", String(INTEG)), + set: { tools_synced_at: null }, + }), + ); + + const firstRead = yield* executor.tools + .list({ integration: INTEG }) + .pipe(Effect.timeoutOption("2 seconds")); + const secondRead = yield* executor.tools + .list({ integration: INTEG }) + .pipe(Effect.timeoutOption("2 seconds")); + expect( + Option.map(firstRead, (tools) => tools.length), + "the read answers from the persisted rows while the rebuilds wait", + ).toEqual(Option.some(5)); + expect(Option.isSome(secondRead)).toBe(true); + expect(maxInFlight, "only one rebuild runs at a time, across both reads").toBe(1); + + yield* Deferred.succeed(release, undefined); + yield* Effect.sync(() => rebuilt.length).pipe( + Effect.repeat({ until: (count) => count >= 5, schedule: Schedule.spaced("10 millis") }), + Effect.timeout("5 seconds"), + ); + yield* Effect.sleep("100 millis"); + expect( + [...rebuilt].sort(), + "every catalog rebuilds exactly once; the second read joined the queued rebuilds", + ).toEqual(names); + expect(maxInFlight).toBe(1); + }), + ), + ); + + it.effect("a retry of a sync that never finished rebuilds after the other stale catalogs", () => + Effect.scoped( + Effect.gen(function* () { + let recording = false; + const order: string[] = []; + const orderedPlugin = definePlugin(() => ({ + id: "ordered" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + resolveTools: ({ connection }) => + Effect.sync(() => { + if (recording) order.push(String(connection.name)); + return { tools: [{ name: ToolName.make("deploy"), description: "deploy" }] }; + }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ slug: INTEG, description: "Vercel", config: {} }), + }), + }))(); + const config = makeTestConfig({ plugins: [orderedPlugin] as const }); + const executor = yield* createExecutor({ + ...config, + toolsSyncConcurrency: 1, + toolsSyncGraceMs: null, + }); + yield* executor.ordered.seed(); + for (const name of ["crashed", "healthy"]) { + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make(name), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", + }); + } + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("integration", "=", String(INTEG)), + set: { tools_synced_at: null }, + }), + ); + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "crashed")), + set: { tools_sync_started_at: Date.now() - 16 * 60_000 }, + }), + ); + recording = true; + + yield* executor.tools.list({ integration: INTEG }); + yield* executor.tools.list({ integration: INTEG }); + expect(order).toEqual(["healthy", "crashed"]); + }), + ), + ); + it.effect( "background sync preserves a nonzero remote catalog when a plugin returns authoritative empty", () => diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 16cebae2fe..810934c436 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -154,6 +154,7 @@ import { import type { FirstPartyOAuthClientConfig } from "./oauth-client"; import { comparePolicyRow, + isUnboundedDynamicToolScope, isValidPattern, matchPattern, positionForNewPattern, @@ -180,6 +181,7 @@ import type { StaticIntegrationDecl, StaticToolDecl, StorageDeps, + PreparedToolPolicy, ToolPolicyProvider, ToolPolicyProviderRule, ToolInvocationCredential, @@ -795,6 +797,15 @@ export interface ExecutorConfig !tool.static).map((tool) => String(tool.integration)), ); @@ -3877,6 +3891,14 @@ export const createExecutor = (effect: Effect.Effect) => catalogPersistLock.withPermits(1)(transaction(effect)); @@ -3968,7 +3990,15 @@ export const createExecutor = entry.mode).pipe( + const produce = produceConnectionToolsUnshared(integrationRow, ref, () => entry.mode); + const run = ( + requestedMode === "background" + ? backgroundRebuildPermits.withPermits(1)(produce) + : produce + ).pipe( Effect.exit, Effect.flatMap((exit) => Deferred.done(entry.deferred, exit)), Effect.ensuring(Effect.sync(() => void toolProductionInFlight.delete(key))), @@ -5131,7 +5166,10 @@ export const createExecutor = !tool.static) @@ -5510,7 +5548,11 @@ export const createExecutor = => findConnectionRow(ref).pipe( Effect.flatMap((fresh) => - fresh === null || oauthReauthRequiredFromProviderState(fresh.provider_state) !== null + fresh === null || + oauthReauthRequiredFromProviderState(fresh.provider_state) !== null || + // A credential verdict cannot refute a failed tool sync; only a + // successful sync clears that record (see `isToolSyncHealth`). + isToolSyncHealth(Option.getOrNull(decodeLastHealth(fresh.last_health))) ? Effect.void : persistHealthResult(ref, fresh, result), ), @@ -5613,6 +5655,17 @@ export const createExecutor = + result.status === "healthy" && previous !== null && isToolSyncHealth(previous) + ? previous + : result, + ), Effect.tap((result) => persistProbeHealthResult(ref, result)), Effect.map((result) => ({ source: "credential_only" as const, @@ -5771,10 +5824,8 @@ export const createExecutor = EffectivePolicy; + readonly resolve: PreparedToolPolicy["resolve"]; + readonly dynamicScope: PreparedToolPolicy["dynamicScope"]; }; const compareProviderPolicyRule = ( @@ -5815,9 +5866,10 @@ export const createExecutor = ({ + Effect.map((prepared) => ({ kind: "prepared" as const, - resolve, + resolve: prepared.resolve, + dynamicScope: prepared.dynamicScope, })), ) : activeToolPolicyProvider.resolve @@ -5893,134 +5945,179 @@ export const createExecutor = [row.slug, row] as const)); - // The TTL only matters when a loaded plugin actually lists a live remote - // catalog; otherwise skip it so age alone never widens the stale query. - const anyRemoteCatalog = Array.from(runtimes.values()).some( - (runtime) => runtime.plugin.remoteToolCatalog === true, - ); - const cutoff = - toolsSyncTtlMs == null || !anyRemoteCatalog ? null : Date.now() - toolsSyncTtlMs; - - // Bound the scan to potentially-stale rows: stale-marked (NULL stamp) or - // synced before the latest instant any trigger could fire at (the TTL - // cutoff / the newest config revision). Per-row trigger checks below - // re-verify against each row's own integration; in steady state this - // query returns nothing and the read pays one indexed lookup. - const latestRevision = integrations.reduce( - (max, row) => - row.config_revised_at == null - ? max - : Math.max(max ?? Number(row.config_revised_at), Number(row.config_revised_at)), - null, - ); - const staleBefore = - cutoff === null && latestRevision === null - ? null - : Math.max(cutoff ?? Number.MIN_SAFE_INTEGER, latestRevision ?? Number.MIN_SAFE_INTEGER); - - const connections = yield* core.findMany("connection", { - where: (b: AnyCb) => - staleBefore === null - ? b.isNull("tools_synced_at") - : b.or(b.isNull("tools_synced_at"), b("tools_synced_at", "<", staleBefore)), - }); - // Each rebuild is an independent upstream listing, so they run together - // rather than one after another: a host with many stale remote-catalog - // connections otherwise pays the sum of every server's latency on the - // read that trips the TTL. Only the listings overlap — `persistCatalog` - // keeps the catalog writes in a single-file queue, so this fan-out never - // opens two transactions on a one-connection database. - const rebuilds: Effect.Effect[] = []; - for (const connection of connections) { - const integrationRow = integrationBySlug.get(connection.integration); - if (!integrationRow) continue; - const runtime = runtimes.get(integrationRow.plugin_id); - // Only re-produce catalogs this executor can actually re-list — - // rebuilding under an unloaded plugin would clear a working catalog. - // (A loaded plugin without `resolveTools` still flows through: - // `produceConnectionTools` runs its clear-and-stamp cleanup path.) - if (!runtime) continue; - - const syncedAt = - connection.tools_synced_at == null ? null : Number(connection.tools_synced_at); - const revisedTime = - integrationRow.config_revised_at == null + const syncStaleConnectionTools = (mode: "converge" | "bounded") => + Effect.gen(function* () { + // The platform view can never persist a rebuilt catalog (writes are + // denied at the storage boundary), so attempting the sync would only + // fire upstream `resolveTools` calls whose results are thrown away — + // network side effects on a read-only credential. Skip it entirely: + // read-only-ness of the platform read path is a stated invariant here, + // not an accident of the best-effort catch below. + if (config.platformView === true) return; + const integrations = yield* core.findMany("integration", {}); + if (integrations.length === 0) return; + const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const)); + // The TTL only matters when a loaded plugin actually lists a live remote + // catalog; otherwise skip it so age alone never widens the stale query. + const anyRemoteCatalog = Array.from(runtimes.values()).some( + (runtime) => runtime.plugin.remoteToolCatalog === true, + ); + const cutoff = + toolsSyncTtlMs == null || !anyRemoteCatalog ? null : Date.now() - toolsSyncTtlMs; + + // Bound the scan to potentially-stale rows: stale-marked (NULL stamp) or + // synced before the latest instant any trigger could fire at (the TTL + // cutoff / the newest config revision). Per-row trigger checks below + // re-verify against each row's own integration; in steady state this + // query returns nothing and the read pays one indexed lookup. + const latestRevision = integrations.reduce( + (max, row) => + row.config_revised_at == null + ? max + : Math.max(max ?? Number(row.config_revised_at), Number(row.config_revised_at)), + null, + ); + const staleBefore = + cutoff === null && latestRevision === null ? null - : Number(integrationRow.config_revised_at); - - const staleMarked = syncedAt === null; - const configRevised = revisedTime !== null && (syncedAt ?? 0) < revisedTime; - const expired = - cutoff !== null && - runtime.plugin.remoteToolCatalog === true && - syncedAt !== null && - syncedAt < cutoff; - if (!staleMarked && !configRevised && !expired) continue; + : Math.max( + cutoff ?? Number.MIN_SAFE_INTEGER, + latestRevision ?? Number.MIN_SAFE_INTEGER, + ); - const startedAt = - connection.tools_sync_started_at == null - ? null - : Number(connection.tools_sync_started_at); - // Every finished attempt clears the start stamp, so a stamp still set - // is an attempt that never reached the end. - if (startedAt !== null && Date.now() - startedAt < TOOLS_SYNC_ATTEMPT_BACKOFF_MS) { - yield* Effect.logWarning( - "executor stale tool sync skipped: previous attempt unfinished", - { - integration: connection.integration, - connection: connection.name, - startedAt, - }, + const connections = yield* core.findMany("connection", { + where: (b: AnyCb) => + staleBefore === null + ? b.isNull("tools_synced_at") + : b.or(b.isNull("tools_synced_at"), b("tools_synced_at", "<", staleBefore)), + }); + // Each rebuild is an independent upstream listing, so they run together + // rather than one after another: a host with many stale remote-catalog + // connections otherwise pays the sum of every server's latency on the + // read that trips the TTL. Only the listings overlap — `persistCatalog` + // keeps the catalog writes in a single-file queue, so this fan-out never + // opens two transactions on a one-connection database. How many run + // at once is capped executor-wide by `toolsSyncConcurrency`. + // + // Two urgency classes. A stale-MARKED or config-revised catalog is known + // wrong (the upstream said so, or the integration's config changed), so + // the read waits for it within the grace budget. A catalog that is only + // older than the TTL is stale-but-working: in bounded mode its rebuild + // runs entirely in the background and the read answers from the + // persisted rows at once. Without that split every read after the TTL + // paid the grace budget for MCP listings it had no reason to wait on. + const urgent: Effect.Effect[] = []; + const deferred: Effect.Effect[] = []; + const urgentRetries: Effect.Effect[] = []; + const deferredRetries: Effect.Effect[] = []; + const retries = (queue: Effect.Effect[]) => + queue === urgent ? urgentRetries : deferredRetries; + for (const connection of connections) { + const integrationRow = integrationBySlug.get(connection.integration); + if (!integrationRow) continue; + const runtime = runtimes.get(integrationRow.plugin_id); + // Only re-produce catalogs this executor can actually re-list — + // rebuilding under an unloaded plugin would clear a working catalog. + // (A loaded plugin without `resolveTools` still flows through: + // `produceConnectionTools` runs its clear-and-stamp cleanup path.) + if (!runtime) continue; + + const syncedAt = + connection.tools_synced_at == null ? null : Number(connection.tools_synced_at); + const revisedTime = + integrationRow.config_revised_at == null + ? null + : Number(integrationRow.config_revised_at); + + const staleMarked = syncedAt === null; + const configRevised = revisedTime !== null && (syncedAt ?? 0) < revisedTime; + const expired = + cutoff !== null && + runtime.plugin.remoteToolCatalog === true && + syncedAt !== null && + syncedAt < cutoff; + if (!staleMarked && !configRevised && !expired) continue; + + const startedAt = + connection.tools_sync_started_at == null + ? null + : Number(connection.tools_sync_started_at); + // A start stamp from a rebuild this executor is still running is not + // a dead attempt: join it (single-flight) rather than skip it. + const inFlightHere = toolProductionInFlight.has( + `${connection.owner}:${connection.integration}:${connection.name}`, ); - continue; - } - - rebuilds.push( - produceConnectionTools( - integrationRow, - { - owner: connection.owner as Owner, - integration: IntegrationSlug.make(connection.integration), - name: ConnectionName.make(connection.name), - }, - "background", - ).pipe( - // Best-effort, but never silent: the read still succeeds on the - // stale-but-working catalog and the peer rebuilds still finish, - // while the operator gets the connection that failed and why. - // Without this a connection whose upstream is permanently broken - // re-fails on every read and leaves no trace anywhere. - Effect.catch((error) => - Effect.logWarning("executor stale tool sync failed", { + // Every finished attempt clears the start stamp, so a stamp still set + // is an attempt that never reached the end. + if ( + !inFlightHere && + startedAt !== null && + Date.now() - startedAt < TOOLS_SYNC_ATTEMPT_BACKOFF_MS + ) { + yield* Effect.logWarning( + "executor stale tool sync skipped: previous attempt unfinished", + { integration: connection.integration, connection: connection.name, - error: describeSyncFailure(error), - }).pipe(Effect.as([] as readonly Tool[])), - ), - Effect.withSpan("executor.tools.sync_stale", { - attributes: { - "executor.integration": connection.integration, - "executor.connection": connection.name, + startedAt, }, - }), - ), + ); + continue; + } + + // A retry of an attempt that died goes to the back of its queue, so + // one catalog that keeps killing its instance cannot keep the others + // from building first. + const queue = staleMarked || configRevised || mode === "converge" ? urgent : deferred; + const retryOfDeadAttempt = !inFlightHere && startedAt !== null; + (retryOfDeadAttempt ? retries(queue) : queue).push( + produceConnectionTools( + integrationRow, + { + owner: connection.owner as Owner, + integration: IntegrationSlug.make(connection.integration), + name: ConnectionName.make(connection.name), + }, + "background", + ).pipe( + // Best-effort, but never silent: the read still succeeds on the + // stale-but-working catalog and the peer rebuilds still finish, + // while the operator gets the connection that failed and why. + // Without this a connection whose upstream is permanently broken + // re-fails on every read and leaves no trace anywhere. + Effect.catch((error) => + Effect.logWarning("executor stale tool sync failed", { + integration: connection.integration, + connection: connection.name, + error: describeSyncFailure(error), + }).pipe(Effect.as([] as readonly Tool[])), + ), + Effect.withSpan("executor.tools.sync_stale", { + attributes: { + "executor.integration": connection.integration, + "executor.connection": connection.name, + }, + }), + ), + ); + } + urgent.push(...urgentRetries); + deferred.push(...deferredRetries); + // Urgent rebuilds are forked first so they reach the shared rebuild + // permits ahead of the deferred ones the read does not wait on. + const urgentFiber = yield* Effect.forkChild( + Effect.all(urgent, { concurrency: STALE_TOOLS_SYNC_CONCURRENCY }), ); - } - yield* Effect.all(rebuilds, { - concurrency: STALE_TOOLS_SYNC_CONCURRENCY, + if (deferred.length > 0) { + const background = yield* Effect.forkDetach( + Effect.all(deferred, { concurrency: STALE_TOOLS_SYNC_CONCURRENCY }), + ); + config.waitUntil?.( + new Promise((resolve) => background.addObserver(() => resolve(undefined))), + ); + } + yield* Fiber.join(urgentFiber); }); - }); // How long a tools read waits for the stale sync before answering from // the persisted rows (`ExecutorConfig.toolsSyncGraceMs`; `null` blocks @@ -6038,51 +6135,97 @@ export const createExecutor = - Effect.gen(function* () { - const fiber = yield* Effect.forkDetach( - syncStaleConnectionTools.pipe( - Effect.catch((error) => - Effect.logWarning("executor stale tool sync scan failed", { - error: describeSyncFailure(error), - }), + const startStaleSync = Effect.gen(function* () { + const fiber = yield* Effect.forkDetach( + syncStaleConnectionTools("bounded").pipe( + Effect.catch((error) => + Effect.logWarning("executor stale tool sync scan failed", { + error: describeSyncFailure(error), + }), + ), + ), + ); + // On hosts that cancel request-scoped I/O once the response settles + // (Cloudflare Workers), hand the host the rebuilds' completion so the + // catalog still converges after the read stops waiting. + config.waitUntil?.( + new Promise((resolve) => fiber.addObserver(() => resolve(undefined))), + ); + return fiber; + }); + + // Restrict a tool-row read to the prefixes an allowlist policy source can + // reach. `null` = no restriction; `false` = nothing reachable, skip the + // read. Any unbounded prefix (a bare `*`, or wildcards in every position) + // makes the whole scope unrestricted. + const dynamicScopeCondition = ( + ruleSet: ActivePolicyRuleSet, + ): ((b: AnyCb) => Condition | boolean) | null | false => { + if (ruleSet.kind !== "prepared" || ruleSet.dynamicScope === undefined) return null; + const scopes = ruleSet.dynamicScope; + if (scopes.length === 0) return false; + if (scopes.some(isUnboundedDynamicToolScope)) return null; + return (b: AnyCb) => + b.or( + ...scopes.map((scope) => + b.and( + scope.integration === null ? true : b("integration", "=", scope.integration), + scope.owner === null ? true : b("owner", "=", scope.owner), + scope.connection === null ? true : b("connection", "=", scope.connection), ), ), ); - // On hosts that cancel request-scoped I/O once the response settles - // (Cloudflare Workers), hand the host the rebuilds' completion so the - // catalog still converges after the read stops waiting. - config.waitUntil?.( - new Promise((resolve) => fiber.addObserver(() => resolve(undefined))), - ); - yield* Fiber.await(fiber).pipe(Effect.timeoutOption(graceMs), Effect.asVoid); - }); + }; - const toolsList = (filter?: ToolListFilter): Effect.Effect => + // `awaitStaleSync: false` still starts the bounded background sync (so + // catalogs converge for sessions that only ever list connections) but + // answers without waiting on it. Visibility-only readers (which + // connections and integrations exist under the active policy) use it: a + // stale catalog does not change which connection a tool belongs to, so + // gating those reads on upstream MCP listings only added latency to every + // session start. In strict (`null` grace) mode the wait is unconditional. + const readTools = ( + filter: ToolListFilter | undefined, + options: { readonly awaitStaleSync: boolean }, + ): Effect.Effect => Effect.gen(function* () { + let syncFiber: Fiber.Fiber | null = null; if (toolsSyncGraceMs === null) { - yield* syncStaleConnectionTools; + yield* syncStaleConnectionTools("converge"); } else { - yield* awaitStaleSyncWithinGrace(toolsSyncGraceMs); + syncFiber = yield* startStaleSync; } + // Fetch the policy snapshot while the sync runs: the scope it carries + // decides which rows to read at all. + const policyRules = yield* listActivePolicyRuleSet(); + if (syncFiber && options.awaitStaleSync) { + yield* Fiber.await(syncFiber).pipe( + Effect.timeoutOption(toolsSyncGraceMs ?? 0), + Effect.asVoid, + ); + } + const scopeCondition = dynamicScopeCondition(policyRules); // Projected: the list surface is metadata (address, description, // annotations) — loading every tool's input/output schema JSON made // an unbounded list scale with schema bytes, not tool count. - const rows = yield* core.findMany("tool", { - where: (b: AnyCb) => - b.and( - filter?.integration === undefined - ? true - : b("integration", "=", String(filter.integration)), - filter?.owner === undefined ? true : b("owner", "=", filter.owner), - filter?.connection === undefined - ? true - : b("connection", "=", String(filter.connection)), - ), - select: TOOL_INVOCATION_COLUMNS, - }); + const rows = + scopeCondition === false + ? [] + : yield* core.findMany("tool", { + where: (b: AnyCb) => + b.and( + filter?.integration === undefined + ? true + : b("integration", "=", String(filter.integration)), + filter?.owner === undefined ? true : b("owner", "=", filter.owner), + filter?.connection === undefined + ? true + : b("connection", "=", String(filter.connection)), + scopeCondition === null ? true : scopeCondition(b), + ), + select: TOOL_INVOCATION_COLUMNS, + }); const includeBlocked = filter?.includeBlocked ?? false; - const policyRules = yield* listActivePolicyRuleSet(); // Only tools whose integration is still in the catalog. A tool row // whose integration was removed is an orphan (a removal that could // not reach this subject's rows): listing it invites an invoke that @@ -6119,6 +6262,9 @@ export const createExecutor = => + readTools(filter, { awaitStaleSync: true }); + const toolSchema = ( address: ToolAddress, ): Effect.Effect => diff --git a/packages/core/sdk/src/github-app.ts b/packages/core/sdk/src/github-app.ts index 29601002a7..20c9ed0784 100644 --- a/packages/core/sdk/src/github-app.ts +++ b/packages/core/sdk/src/github-app.ts @@ -59,13 +59,13 @@ const PKCS8_RSA_PREAMBLE = [ * WebCrypto's `importKey` only accepts `pkcs8`. The wrap is a fixed ASN.1 * envelope around the original bytes, so it is a re-encoding, not a conversion. */ -const pkcs1ToPkcs8 = (pkcs1: Uint8Array): Uint8Array => { +const pkcs1ToPkcs8 = (pkcs1: Uint8Array): Uint8Array => { const body = [...PKCS8_RSA_PREAMBLE, 0x04, ...derLength(pkcs1.length), ...pkcs1]; return new Uint8Array([0x30, ...derLength(body.length), ...body]); }; /** Decode a PEM body to DER, wrapping PKCS#1 keys so WebCrypto accepts them. */ -const derFromPem = (pem: string): Uint8Array => { +const derFromPem = (pem: string): Uint8Array => { const body = pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); const der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0)); return /BEGIN RSA PRIVATE KEY/.test(pem) ? pkcs1ToPkcs8(der) : der; diff --git a/packages/core/sdk/src/health-check.ts b/packages/core/sdk/src/health-check.ts index a4d6f1cb20..9e39047d55 100644 --- a/packages/core/sdk/src/health-check.ts +++ b/packages/core/sdk/src/health-check.ts @@ -145,6 +145,7 @@ export type HealthCheckResult = typeof HealthCheckResult.Type; export const toolSyncHealthDetailPrefix = "Tool sync failing"; export const isToolSyncHealth = (result: HealthCheckResult | null | undefined): boolean => + result?.reason === "tool_sync_failed" || result?.detail?.startsWith(toolSyncHealthDetailPrefix) === true; // --------------------------------------------------------------------------- diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 56d7d5b777..627d3de1de 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -192,7 +192,10 @@ export { export { matchPattern, isValidPattern, + dynamicToolScopeForPattern, + isUnboundedDynamicToolScope, effectivePolicyFromSorted, + type DynamicToolScope, ToolPolicyActionSchema, type ToolPolicy, type CreateToolPolicyInput, @@ -388,6 +391,7 @@ export { type AnyPlugin, type StorageDeps, type OwnerBinding, + type PreparedToolPolicy, type ToolPolicyProvider, type ToolPolicyProviderRule, type IntegrationRecord, diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index 8b97660a1f..688c2f6ae0 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -2039,6 +2039,73 @@ describe("oauth token refresh in resolveConnectionValue", () => { ), ); + it.effect( + "checkHealth without a probe serves a sync-stamped verdict instead of burying it under healthy", + () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + resource: server.mcpResourceUrl, + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + // Tool sync found the upstream rejecting the freshly minted token + // (e.g. an MCP discovery handshake answering 401) and stamped it. + // The token itself still resolves, so a credential-only check would + // otherwise report healthy and hide a connection that has no tools. + const stamped = { + status: "expired", + checkedAt: Date.now(), + detail: "MCP OAuth reauthorization required", + reason: "tool_sync_failed", + }; + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { last_health: stamped }, + }), + ); + + const result = yield* executor.connections.checkHealth({ + owner: "org", + integration: INTEG, + name: ConnectionName.make("main"), + }); + expect(result).toMatchObject(stamped); + + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect(row?.last_health).toMatchObject(stamped); + }), + ), + ); + it.effect("records missing authorization-code scopes without blocking the connection", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index d94e8c34ad..92bc623d06 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -621,6 +621,7 @@ describe("exchangeAuthorizationCode", () => { it.effect("uses nested granted scopes for Slack-style user token responses", () => withTokenEndpoint( tokenResponse({ + ok: true, access_token: "xoxp-user-token", token_type: "Bearer", scope: "", @@ -1838,3 +1839,155 @@ describe("OAuth2Error tagging", () => { }); }); }); + +// Slack labels bearer credentials by actor type. The same envelope is used +// during authorization-code exchange and refresh-token rotation. +describe("Provider token envelopes", () => { + const grants = [ + { + label: "standard bearer response with ok metadata", + body: { + ok: true, + access_token: "provider-token", + token_type: "Bearer", + scope: "scope,with-comma other.scope", + }, + expected: { + access_token: "provider-token", + token_type: "bearer", + scope: "scope,with-comma other.scope", + }, + }, + { + // https://docs.slack.dev/reference/methods/oauth.v2.access/ + // A single response can contain two distinct accounts and refresh tokens. + label: "Slack bot and user response", + body: { + ok: true, + access_token: "bot-token", + token_type: "bot", + scope: "commands,incoming-webhook", + expires_in: 43200, + refresh_token: "bot-refresh", + authed_user: { + access_token: "user-token", + token_type: "user", + scope: "chat:write", + expires_in: 43200, + refresh_token: "user-refresh", + }, + }, + expected: { + access_token: "bot-token", + token_type: "bearer", + scope: "commands incoming-webhook", + expires_in: 43200, + refresh_token: "bot-refresh", + }, + }, + { + label: "bot", + body: { + ok: true, + access_token: "bot-token", + token_type: "bot", + scope: "channels:read,chat:write", + refresh_token: "bot-refresh", + expires_in: 3600, + }, + expected: { + access_token: "bot-token", + token_type: "bearer", + scope: "channels:read chat:write", + refresh_token: "bot-refresh", + expires_in: 3600, + }, + }, + { + label: "user", + body: { + ok: true, + access_token: "user-token", + token_type: "user", + scope: "users:read,users:read.email", + refresh_token: "user-refresh", + expires_in: 3600, + }, + expected: { + access_token: "user-token", + token_type: "bearer", + scope: "users:read users:read.email", + refresh_token: "user-refresh", + expires_in: 3600, + }, + }, + { + label: "nested user", + body: { + ok: true, + authed_user: { + access_token: "nested-user-token", + token_type: "user", + scope: "users:read,chat:write", + refresh_token: "nested-refresh", + expires_in: 3600, + }, + }, + expected: { + access_token: "nested-user-token", + token_type: "bearer", + scope: "users:read chat:write", + refresh_token: "nested-refresh", + expires_in: 3600, + }, + }, + ]; + for (const grant of grants) { + it.effect(`exchanges a ${grant.label} grant`, () => + withTokenEndpoint(tokenResponse(grant.body), ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + redirectUrl: "https://app.example/callback", + codeVerifier: "verifier", + code: "code", + }); + expect(result).toMatchObject(grant.expected); + }), + ), + ); + it.effect(`refreshes a ${grant.label} grant`, () => + withTokenEndpoint(tokenResponse(grant.body), ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* refreshAccessToken({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + refreshToken: "old-refresh", + }); + expect(result).toMatchObject(grant.expected); + }), + ), + ); + } + it.effect("still rejects unsupported token types in an otherwise successful envelope", () => + withTokenEndpoint( + tokenResponse({ ok: true, access_token: "token", token_type: "mac" }), + ({ tokenUrl }) => + Effect.gen(function* () { + const exit = yield* Effect.exit( + exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + redirectUrl: "https://app.example/callback", + codeVerifier: "verifier", + code: "code", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index c6e3209fe7..b8b11714c2 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -1117,16 +1117,74 @@ const stripIdToken = async (response: Response): Promise }; }; +const SlackGrant = Schema.Struct({ + access_token: Schema.optional(Schema.String), + token_type: Schema.optional(Schema.Literals(["bot", "user", "Bearer", "bearer"])), + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + scope: Schema.optional(Schema.String), +}); +const decodeSlackEnvelope = Schema.decodeUnknownOption( + Schema.Struct({ + ...SlackGrant.fields, + ok: Schema.Literal(true), + authed_user: Schema.optional(SlackGrant), + }), +); + +/** Slack's `bot` and `user` values identify the account, not an HTTP auth + * scheme. Project its successful envelope to an RFC 6749 bearer grant before + * oauth4webapi validates it. User-only grants may live entirely in authed_user; + * never replace a populated top-level grant with another account's grant. */ +const normalizeSlackTokenEnvelope = async (response: Response): Promise => { + const decoded = decodeSlackEnvelope(await safeJsonFromResponse(response)); + if (Option.isNone(decoded)) return response; + const envelope = decoded.value; + const user = envelope.authed_user; + const grant = + user?.access_token !== undefined && + (envelope.access_token === undefined || !envelope.scope?.trim()) + ? user + : envelope; + // Standard bearer responses may also contain `ok: true`. Preserve their + // scopes and provider metadata; only Slack's actor token types need adapting. + if ( + grant.access_token === undefined || + (grant.token_type !== "bot" && grant.token_type !== "user") + ) { + return response; + } + const scope = grant.scope + ?.split(/[\s,]+/) + .filter(Boolean) + .join(" "); + return new Response( + JSON.stringify({ + access_token: grant.access_token, + token_type: "Bearer", + refresh_token: grant.refresh_token, + expires_in: grant.expires_in, + ...(scope ? { scope } : {}), + }), + { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }, + ); +}; + const processTokenEndpointResponse = async ( as: oauth.AuthorizationServer, client: oauth.Client, response: Response, ): Promise => { const stripped = await stripIdToken(response); - const providerUserGrant = await nestedAuthedUserGrant(stripped.response); + const normalizedResponse = await normalizeSlackTokenEnvelope(stripped.response); + const providerUserGrant = await nestedAuthedUserGrant(normalizedResponse); const parsed = tokenResponseFrom( as, - await oauth.processGenericTokenEndpointResponse(as, client, stripped.response), + await oauth.processGenericTokenEndpointResponse(as, client, normalizedResponse), ); const token = parsed.scope === undefined && providerUserGrant !== undefined @@ -1442,7 +1500,7 @@ export const refreshAccessToken = ( const result = await oauth.processRefreshTokenResponse( as, client, - (await stripIdToken(response)).response, + await normalizeSlackTokenEnvelope((await stripIdToken(response)).response), ); return tokenResponseFrom(as, result); }, diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 47b92d16e0..97dbd3dce5 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -713,13 +713,42 @@ describe("oauth.start integration-driven scopes", () => { ), ); - it.effect("(j) caps server-advertised resource scopes so the authorize URL stays bounded", () => + it.effect("(j) requests every advertised scope of a large but realistic resource list", () => Effect.scoped( Effect.gen(function* () { - // A hostile/buggy server advertises far more scopes than any real - // template. Discovery caps the request at 100 so the authorize URL - // cannot be blown up. - const manyScopes = Array.from({ length: 200 }, (_, i) => `scope:${i}`); + // A fine-grained resource can legitimately advertise well over a + // hundred scopes (PostHog lists 150). Dropping any of them mints a + // token the resource rejects, so the whole list must be requested. + const manyScopes = Array.from( + { length: 150 }, + (_, i) => `resource_${i}:${i % 2 === 0 ? "read" : "write"}`, + ); + const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } }); + const executor = yield* setupMcpScopeClient(server); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(manyScopes); + }), + ), + ); + + it.effect("(j2) caps server-advertised resource scopes so the authorize URL stays bounded", () => + Effect.scoped( + Effect.gen(function* () { + // A hostile/buggy server advertises an absurd list. Discovery keeps + // the longest leading prefix whose joined `scope` value fits the + // 8 KiB budget so the authorize URL cannot be blown up. + const manyScopes = Array.from({ length: 2000 }, (_, i) => `scope:${i}`); const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } }); const executor = yield* setupMcpScopeClient(server); @@ -735,8 +764,10 @@ describe("oauth.start integration-driven scopes", () => { if (started.status !== "redirect") return; const requested = scopesFromAuthorizeUrl(started.authorizationUrl); - expect(requested.length).toBe(100); - expect(requested).toEqual(manyScopes.slice(0, 100)); + expect(requested.length).toBeLessThan(manyScopes.length); + expect(requested).toEqual(manyScopes.slice(0, requested.length)); + expect(requested.join(" ").length).toBeLessThanOrEqual(8192); + expect([...requested, manyScopes[requested.length]].join(" ").length).toBeGreaterThan(8192); }), ), ); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 30bedc934d..2b799bd944 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -786,9 +786,26 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // Caps on server-controlled discovery input — a hostile or buggy server must // not be able to hang `oauth.start` or overflow the authorize URL. const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice - const MAX_DISCOVERED_SCOPES = 100; // far beyond any realistic authorization template - const capScopes = (scopes: readonly string[]): readonly string[] => - dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES); + // The cap is on the encoded `scope` parameter's length, not the scope + // count: the URL is what overflows, and a real resource can legitimately + // advertise well over a hundred fine-grained scopes (PostHog lists 150). + // Dropping any advertised scope silently mints a token the resource then + // rejects, so the budget is generous — 8 KiB leaves room for the rest of the + // authorize URL under the common 8-16 KiB request-line limits — and only an + // absurd list is truncated. + const MAX_DISCOVERED_SCOPE_CHARS = 8192; + const capScopes = (scopes: readonly string[]): readonly string[] => { + const unique = dedupeScopes(scopes); + let length = 0; + let count = 0; + for (const scope of unique) { + const next = length + scope.length + (count > 0 ? 1 : 0); + if (next > MAX_DISCOVERED_SCOPE_CHARS) break; + length = next; + count += 1; + } + return unique.slice(0, count); + }; // Bound a whole discovery sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS // fetches, each with its own request timeout). 30s is larger than a single diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 2ace32f891..e079f5ab8a 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -55,6 +55,7 @@ import type { CredentialProvider, ProviderEntry } from "./provider"; import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; import type { CreateToolPolicyInput, + DynamicToolScope, EffectivePolicy, RemoveToolPolicyInput, ToolPolicy, @@ -131,13 +132,25 @@ export interface ToolPolicyProvider { * requests), so caching on it would serve stale policy state. Each operation * gets a fresh snapshot. */ - readonly prepare?: () => Effect.Effect< - (input: { - readonly toolId: string; - readonly defaultRequiresApproval?: boolean; - }) => EffectivePolicy, - StorageFailure - >; + readonly prepare?: () => Effect.Effect; +} + +/** What `ToolPolicyProvider.prepare` hands core for one operation. */ +export interface PreparedToolPolicy { + /** Pure resolver over the snapshot `prepare` fetched. */ + readonly resolve: (input: { + readonly toolId: string; + readonly defaultRequiresApproval?: boolean; + }) => EffectivePolicy; + /** + * The dynamic-tool prefixes this policy source can ever approve. When set, + * core restricts the tool rows it loads on a list to these prefixes instead + * of reading the whole catalog and blocking most of it in memory — the read + * then scales with the allowlist, not the workspace. An empty array means no + * dynamic tool is reachable. Omit when the source is not an allowlist (any + * row may be approved) so core keeps the unrestricted read. + */ + readonly dynamicScope?: readonly DynamicToolScope[]; } // --------------------------------------------------------------------------- diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts index 18c5d29a72..98af2a65e7 100644 --- a/packages/core/sdk/src/policies.test.ts +++ b/packages/core/sdk/src/policies.test.ts @@ -16,6 +16,7 @@ import { ElicitationResponse, type ElicitationHandler } from "./elicitation"; import { createExecutor } from "./executor"; import type { FumaDb } from "./fuma-runtime"; import { + dynamicToolScopeForPattern, effectivePolicyFromSorted, isValidPattern, matchPattern, @@ -108,6 +109,56 @@ describe("isValidPattern", () => { }); }); +describe("dynamicToolScopeForPattern", () => { + it("reads the connection prefix out of subtree patterns", () => { + expect(dynamicToolScopeForPattern("github.org.main.*")).toEqual({ + integration: "github", + owner: "org", + connection: "main", + }); + expect(dynamicToolScopeForPattern("github.org.*")).toEqual({ + integration: "github", + owner: "org", + connection: null, + }); + expect(dynamicToolScopeForPattern("github.*")).toEqual({ + integration: "github", + owner: null, + connection: null, + }); + }); + + it("treats a mid-segment wildcard as any value for that position", () => { + expect(dynamicToolScopeForPattern("github.*.*.repos.list")).toEqual({ + integration: "github", + owner: null, + connection: null, + }); + expect(dynamicToolScopeForPattern("github.user.*.repos.*")).toEqual({ + integration: "github", + owner: "user", + connection: null, + }); + }); + + it("is unbounded for the universal pattern", () => { + expect(dynamicToolScopeForPattern("*")).toEqual({ + integration: null, + owner: null, + connection: null, + }); + }); + + it("yields no scope for patterns that can only reach static tools", () => { + // Exact ids shorter than a dynamic address. + expect(dynamicToolScopeForPattern("github")).toBeNull(); + expect(dynamicToolScopeForPattern("github.org.main")).toBeNull(); + // A literal owner that is neither org nor user is a static namespace. + expect(dynamicToolScopeForPattern("executor.coreTools.*")).toBeNull(); + expect(dynamicToolScopeForPattern("executor.coreTools.connections.list")).toBeNull(); + }); +}); + describe("resolveToolPolicy", () => { // v2: policy rows carry `owner` (org|user) instead of a scope id. const ROW = ( @@ -714,6 +765,100 @@ describe("active tool-policy provider", () => { ); }); +describe("prepared tool policy provider with a dynamic scope", () => { + const scopedProviderPlugin = ( + dynamicScope: readonly { + integration: string | null; + owner: string | null; + connection: string | null; + }[], + ) => + definePlugin(() => ({ + id: "scoped-policy-provider" as const, + storage: () => ({}), + toolPolicyProvider: () => ({ + list: () => Effect.succeed([]), + // Approves everything it is asked about: only the scope decides what + // core reads, so anything missing from the list was never loaded. + prepare: () => + Effect.succeed({ + resolve: () => ({ action: "approve" as const, source: "user" as const, pattern: "*" }), + dynamicScope, + }), + }), + }))(); + + const setupScoped = ( + dynamicScope: readonly { + integration: string | null; + owner: string | null; + connection: string | null; + }[], + ) => + makeTestExecutor({ + plugins: [policyTestPlugin(), scopedProviderPlugin(dynamicScope)] as const, + }).pipe( + Effect.tap((executor) => + Effect.gen(function* () { + yield* executor.ptest.seed(); + for (const integration of [VERCEL, GITHUB]) { + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration, + template: TEMPLATE, + value: "v", + }); + } + }), + ), + ); + + const dynamicAddresses = (tools: readonly { address: unknown; static?: boolean }[]) => + tools + .filter((tool) => !tool.static) + .map((tool) => String(tool.address)) + .sort(); + + it.effect("restricts the list to the scoped connection", () => + Effect.gen(function* () { + const executor = yield* setupScoped([ + { integration: String(VERCEL), owner: "org", connection: String(CONN) }, + ]); + const tools = yield* executor.tools.list(); + expect(dynamicAddresses(tools)).toEqual([ + String(addr(VERCEL, "delete")), + String(addr(VERCEL, "deploy")), + ]); + const connections = yield* executor.connections.list(); + expect(connections.map((connection) => String(connection.integration))).toEqual([ + String(VERCEL), + ]); + }), + ); + + it.effect("a wildcard position widens the scope to every value", () => + Effect.gen(function* () { + const executor = yield* setupScoped([{ integration: null, owner: "org", connection: null }]); + const tools = yield* executor.tools.list(); + expect(dynamicAddresses(tools)).toEqual([ + String(addr(GITHUB, "list")), + String(addr(VERCEL, "delete")), + String(addr(VERCEL, "deploy")), + ]); + }), + ); + + it.effect("an empty scope reads no dynamic rows", () => + Effect.gen(function* () { + const executor = yield* setupScoped([]); + const tools = yield* executor.tools.list(); + expect(dynamicAddresses(tools)).toEqual([]); + expect(yield* executor.connections.list()).toEqual([]); + }), + ); +}); + describe("approve / require_approval interaction with annotations", () => { it.effect("approve skips the elicitation prompt even when plugin requires approval", () => Effect.gen(function* () { diff --git a/packages/core/sdk/src/policies.ts b/packages/core/sdk/src/policies.ts index 8620d9c6d3..b9e1f1ecad 100644 --- a/packages/core/sdk/src/policies.ts +++ b/packages/core/sdk/src/policies.ts @@ -120,6 +120,49 @@ export const isValidPattern = (pattern: string): boolean => { return true; }; +// --------------------------------------------------------------------------- +// Dynamic-tool scope — the (integration, owner, connection) prefix a pattern +// can reach. Lets a policy source that is an allowlist (a toolkit) narrow the +// tool rows core loads to the connections the allowlist names, instead of +// walking the whole catalog and blocking almost all of it in memory. +// --------------------------------------------------------------------------- + +/** One reachable prefix of a dynamic tool id `integration.owner.connection.tool`. + * `null` in a position means any value. All three `null` = unbounded. */ +export interface DynamicToolScope { + readonly integration: string | null; + readonly owner: string | null; + readonly connection: string | null; +} + +export const isUnboundedDynamicToolScope = (scope: DynamicToolScope): boolean => + scope.integration === null && scope.owner === null && scope.connection === null; + +/** + * The prefix of dynamic tool ids a pattern can match, or `null` when it can + * match none. A dynamic tool id has at least four segments, so an exact + * pattern shorter than that reaches only static tools. A trailing `*` covers + * every deeper segment; a mid-pattern `*` covers exactly that segment. + */ +export const dynamicToolScopeForPattern = (pattern: string): DynamicToolScope | null => { + if (pattern === "*") return { integration: null, owner: null, connection: null }; + const segments = pattern.split("."); + const subtree = segments.at(-1) === "*"; + if (!subtree && segments.length < 4) return null; + const at = (index: number): string | null => { + const segment = segments[index]; + if (segment === undefined) return null; + // Only the trailing `*` reaches past its own position; a mid `*` is one + // segment, which is also "any value" for that position. + return segment === "*" ? null : segment; + }; + const owner = at(1); + // A dynamic tool id's owner segment is always `org` or `user`; any other + // literal there names a static namespace (`executor.coreTools.*`). + if (owner !== null && owner !== "org" && owner !== "user") return null; + return { integration: at(0), owner, connection: at(2) }; +}; + // --------------------------------------------------------------------------- // Resolution — each owner contributes its first matching rule by local // position; the most restrictive matched action across owners wins. Caller diff --git a/packages/core/vite-plugin/CHANGELOG.md b/packages/core/vite-plugin/CHANGELOG.md index ab73ab229f..dafa62eb8b 100644 --- a/packages/core/vite-plugin/CHANGELOG.md +++ b/packages/core/vite-plugin/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/vite-plugin +## 0.0.70 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 0.0.69 ### Patch Changes diff --git a/packages/core/vite-plugin/package.json b/packages/core/vite-plugin/package.json index 8d013f6dd6..4c9203e8d6 100644 --- a/packages/core/vite-plugin/package.json +++ b/packages/core/vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/vite-plugin", - "version": "0.0.69", + "version": "0.0.70", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/vite-plugin", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/hosts/cloudflare/CHANGELOG.md b/packages/hosts/cloudflare/CHANGELOG.md index 8d548affc0..5e54606529 100644 --- a/packages/hosts/cloudflare/CHANGELOG.md +++ b/packages/hosts/cloudflare/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/cloudflare +## 0.0.52 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/execution@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/host-mcp@1.4.4 + ## 0.0.51 ### Patch Changes diff --git a/packages/hosts/cloudflare/package.json b/packages/hosts/cloudflare/package.json index d18022c193..a04546dbd4 100644 --- a/packages/hosts/cloudflare/package.json +++ b/packages/hosts/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloudflare", - "version": "0.0.51", + "version": "0.0.52", "private": true, "type": "module", "exports": { diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 5a90dc5978..7613459221 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -21,7 +21,13 @@ import { } from "@executor-js/host-mcp/tool-server"; import { defaultMcpResource, mcpResourceKey, type McpResource } from "@executor-js/host-mcp"; import { decodeResumeResponse, type McpToolMode } from "@executor-js/host-mcp/browser-approval"; -import { ElicitationResponse } from "@executor-js/sdk"; +import { + CurrentOrgWriteAccess, + ElicitationResponse, + currentOrgWriteAccess, + makeOrgWriteAccessState, + type OrgWriteAccess, +} from "@executor-js/sdk"; import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; import { classifyDurableObjectError, type DurableObjectFailure } from "./durable-object-errors"; @@ -97,6 +103,12 @@ export type McpApprovalOwner = { readonly organizationId: string; }; +/** A model `resume` forwarded from another session of the same owner, carrying + * the workspace-write access its own request was authenticated with. */ +export type McpModelResumeCaller = McpApprovalOwner & { + readonly orgWriteAccess: OrgWriteAccess; +}; + /** Authenticated browser approver with a freshly resolved organization role. */ export type McpApprovalPrincipal = McpApprovalOwner & { readonly orgRole: "admin" | "member"; @@ -546,7 +558,7 @@ export abstract class McpAgentSessionDOBase< protected forwardModelResumeToOwner( _owner: McpExecutionOwnerRoute, - _identity: McpApprovalOwner, + _identity: McpModelResumeCaller, _executionId: string, _response: ResumeResponse, ): Effect.Effect { @@ -1678,7 +1690,7 @@ export abstract class McpAgentSessionDOBase< async resumeExecutionForModel( executionId: string, - identity: McpApprovalOwner, + identity: McpModelResumeCaller, response: ResumeResponse, incoming?: IncomingTraceHeaders, ): Promise { @@ -1698,7 +1710,17 @@ export abstract class McpAgentSessionDOBase< return { status: "execution_expired" as const, ttlMs: PAUSED_APPROVAL_TIMEOUT_MS }; } - const outcome = yield* self.resumeEngineWithLifecycle(executionId, response); + // This RPC runs outside any MCP request, so nothing else binds the + // caller's workspace-write access; without it the resume would rebind + // the paused execution to the fail-closed default and deny an admin's + // pending write. A caller that predates the field is treated as denied. + const orgWriteAccess: OrgWriteAccess = + identity.orgWriteAccess === "allowed" ? "allowed" : "denied"; + const outcome = yield* self + .resumeEngineWithLifecycle(executionId, response) + .pipe( + Effect.provideService(CurrentOrgWriteAccess, makeOrgWriteAccessState(orgWriteAccess)), + ); if (!outcome) { const alreadySettled = self.engine.isExecutionSettled ? yield* self.engine.isExecutionSettled(executionId) @@ -1959,9 +1981,10 @@ export abstract class McpAgentSessionDOBase< const sessionMeta = yield* self.loadSessionMeta(); if (!sessionMeta) return { status: "execution_forbidden" } as const; - const identity: McpApprovalOwner = { + const identity: McpModelResumeCaller = { accountId: sessionMeta.userId, organizationId: sessionMeta.organizationId, + orgWriteAccess: yield* currentOrgWriteAccess, }; if ( identity.accountId !== record.accountId || diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts index 8a95011ed4..7c8128c8b0 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts @@ -2,6 +2,12 @@ import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; // oxlint-disable-next-line executor/no-vitest-import -- boundary: vi.mock must come from vitest itself for mock hoisting to resolve import { vi } from "vitest"; import { Cause, Effect } from "effect"; +import { + CurrentOrgWriteAccess, + currentOrgWriteAccess, + makeOrgWriteAccessState, + type OrgWriteAccess, +} from "@executor-js/sdk"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { defaultMcpResource } from "@executor-js/host-mcp"; import { @@ -18,7 +24,7 @@ import type { import { McpAgentSessionDOBase, type BuiltMcpServer, - type McpApprovalOwner, + type McpModelResumeCaller, type McpSessionInit, type McpSessionModelResumeResult, type SessionMeta, @@ -249,8 +255,10 @@ const makeEngine = ( resultForResume: (executionId: string, response: ResumeResponse) => ExecutionResult | null, ) => { const calls: ResumeCall[] = []; + const orgWriteAccesses: OrgWriteAccess[] = []; const resume = vi.fn((executionId: string, response: ResumeResponse) => - Effect.sync(() => { + Effect.gen(function* () { + orgWriteAccesses.push(yield* currentOrgWriteAccess); calls.push({ executionId, response }); return resultForResume(executionId, response); }), @@ -266,7 +274,7 @@ const makeEngine = ( // The fake forks nothing, so there is no sandbox fiber to end. shutdown: Effect.void, }; - return { calls, engine, resume }; + return { calls, engine, orgWriteAccesses, resume }; }; const sessionMeta = (input?: Partial): SessionMeta => ({ @@ -299,7 +307,7 @@ class HarnessSession extends McpAgentSessionDOBase private readonly directory: McpExecutionOwnerDirectory | null; private readonly modelResumeForward: ( owner: McpExecutionOwnerRoute, - identity: McpApprovalOwner, + identity: McpModelResumeCaller, executionId: string, response: ResumeResponse, ) => Effect.Effect; @@ -337,7 +345,7 @@ class HarnessSession extends McpAgentSessionDOBase protected override forwardModelResumeToOwner( owner: McpExecutionOwnerRoute, - identity: McpApprovalOwner, + identity: McpModelResumeCaller, executionId: string, response: ResumeResponse, ): Effect.Effect { @@ -379,13 +387,21 @@ class HarnessSession extends McpAgentSessionDOBase await this.fakeState.flushWaitUntil(); } + /** Resume as the MCP `resume` tool does, under the request's write access. */ async resumeViaModelTool( executionId: string, response: ResumeResponse, + orgWriteAccess: OrgWriteAccess = "denied", ): Promise { - const local = await Effect.runPromise(this["engine"]!.resume(executionId, response)); + const bound = Effect.provideService( + CurrentOrgWriteAccess, + makeOrgWriteAccessState(orgWriteAccess), + ); + const local = await Effect.runPromise( + this["engine"]!.resume(executionId, response).pipe(bound), + ); if (local) return { status: "result", result: formatMcpExecutionOutcome(local) }; - return Effect.runPromise(this.modelResumeFallback(executionId, response)); + return Effect.runPromise(this.modelResumeFallback(executionId, response).pipe(bound)); } pendingLease(executionId: string): PendingApprovalLeaseSnapshot | undefined { @@ -466,7 +482,7 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { const forward = vi.fn( ( owner: McpExecutionOwnerRoute, - identity: McpApprovalOwner, + identity: McpModelResumeCaller, executionId: string, response: ResumeResponse, ) => @@ -534,6 +550,67 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { expect(ownerEngine.calls).toEqual([{ executionId: "exec_owner", response: approval }]); }); + for (const orgWriteAccess of ["allowed", "denied"] as const) { + it(`resumes the owning session under the requester's ${orgWriteAccess} workspace-write access`, async () => { + const { namespace } = makeDirectory(); + const ownerEngine = makeEngine(() => completed("owner-result")); + const requesterEngine = makeEngine(() => null); + const sessions = new Map(); + const sessionNamespace = { + idFromName: (name: string) => name, + get: (id: string) => sessions.get(id), + }; + const sessionA = new HarnessSession({ + sessionId: "session-a", + engine: ownerEngine.engine, + directoryNamespace: namespace, + }); + const sessionB = new HarnessSession({ + sessionId: "session-b", + engine: requesterEngine.engine, + directoryNamespace: namespace, + forwardModelResumeToOwner: (owner, identity, executionId, response) => + Effect.promise(() => + mcpSessionStub(sessionNamespace, owner.sessionId).resumeExecutionForModel( + executionId, + identity, + response, + ), + ), + }); + sessions.set(mcpSessionDurableObjectName("session-a"), sessionA); + await sessionA.storeSessionMeta(); + await sessionB.storeSessionMeta(); + await sessionA.startPause("exec_owner"); + + await sessionB.resumeViaModelTool("exec_owner", approval, orgWriteAccess); + + expect(ownerEngine.orgWriteAccesses).toEqual([orgWriteAccess]); + }); + } + + it("treats a forwarded resume without workspace-write access as denied", async () => { + const { namespace } = makeDirectory(); + const ownerEngine = makeEngine(() => completed("owner-result")); + const sessionA = new HarnessSession({ + sessionId: "session-a", + engine: ownerEngine.engine, + directoryNamespace: namespace, + }); + await sessionA.storeSessionMeta(); + await sessionA.startPause("exec_owner"); + + // A requester still running the previous deploy sends only the owner pair. + const legacyIdentity = { accountId: "acct_1", organizationId: "org_1" }; + await sessionA.resumeExecutionForModel( + "exec_owner", + legacyIdentity as McpModelResumeCaller, + approval, + ); + + expect(ownerEngine.orgWriteAccesses).toEqual(["denied"]); + }); + it("rejects identity mismatch without invoking the owning session engine", async () => { const { directory, namespace } = makeDirectory(); const ownerEngine = makeEngine(() => completed("should-not-run")); diff --git a/packages/hosts/cloudflare/src/mcp/session-stub.ts b/packages/hosts/cloudflare/src/mcp/session-stub.ts index 696e16b304..184161e908 100644 --- a/packages/hosts/cloudflare/src/mcp/session-stub.ts +++ b/packages/hosts/cloudflare/src/mcp/session-stub.ts @@ -5,6 +5,7 @@ import type { IncomingTraceHeaders, McpApprovalOwner, McpApprovalPrincipal, + McpModelResumeCaller, McpSessionApprovalResult, McpSessionModelResumeResult, McpSessionResumeApprovalResult, @@ -35,7 +36,7 @@ export interface McpSessionStub { ) => Promise; readonly resumeExecutionForModel: ( executionId: string, - identity: McpApprovalOwner, + identity: McpModelResumeCaller, response: ResumeResponse, incoming?: IncomingTraceHeaders, ) => Promise; diff --git a/packages/hosts/mcp-apps-shell/CHANGELOG.md b/packages/hosts/mcp-apps-shell/CHANGELOG.md index 4c7426080c..dbeb773c76 100644 --- a/packages/hosts/mcp-apps-shell/CHANGELOG.md +++ b/packages/hosts/mcp-apps-shell/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/mcp-apps-shell +## 1.4.21 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/runtime-quickjs@1.6.10 + - @executor-js/react@1.4.73 + ## 1.4.20 ### Patch Changes diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json index abd6616b7f..e1615bdcc2 100644 --- a/packages/hosts/mcp-apps-shell/package.json +++ b/packages/hosts/mcp-apps-shell/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/mcp-apps-shell", - "version": "1.4.20", + "version": "1.4.21", "private": true, "type": "module", "exports": { diff --git a/packages/kernel/core/CHANGELOG.md b/packages/kernel/core/CHANGELOG.md index ff5850ac6a..eed11fadab 100644 --- a/packages/kernel/core/CHANGELOG.md +++ b/packages/kernel/core/CHANGELOG.md @@ -1,5 +1,7 @@ # @executor-js/codemode-core +## 1.6.10 + ## 1.6.9 ## 1.6.8 diff --git a/packages/kernel/core/package.json b/packages/kernel/core/package.json index d2952db08a..a1d24ebbe7 100644 --- a/packages/kernel/core/package.json +++ b/packages/kernel/core/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/codemode-core", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/core", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/kernel/runtime-quickjs/CHANGELOG.md b/packages/kernel/runtime-quickjs/CHANGELOG.md index 19a9cde4ea..5425da04d1 100644 --- a/packages/kernel/runtime-quickjs/CHANGELOG.md +++ b/packages/kernel/runtime-quickjs/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/runtime-quickjs +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/codemode-core@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/kernel/runtime-quickjs/package.json b/packages/kernel/runtime-quickjs/package.json index 082895092f..029a937e2e 100644 --- a/packages/kernel/runtime-quickjs/package.json +++ b/packages/kernel/runtime-quickjs/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/runtime-quickjs", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/runtime-quickjs", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md b/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md index 0c0aa1bcab..bc94e1566c 100644 --- a/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md +++ b/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/runtime-workerd-subprocess +## 0.0.25 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/codemode-core@1.6.10 + ## 0.0.24 ### Patch Changes diff --git a/packages/kernel/runtime-workerd-subprocess/package.json b/packages/kernel/runtime-workerd-subprocess/package.json index cb98709333..be832a7e18 100644 --- a/packages/kernel/runtime-workerd-subprocess/package.json +++ b/packages/kernel/runtime-workerd-subprocess/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/runtime-workerd-subprocess", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/onboarding-demo/CHANGELOG.md b/packages/onboarding-demo/CHANGELOG.md index 886ee25d63..5af39e4d7a 100644 --- a/packages/onboarding-demo/CHANGELOG.md +++ b/packages/onboarding-demo/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/onboarding-demo +## 0.0.5 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/plugin-mcp@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + - @executor-js/react@1.4.73 + ## 0.0.4 ### Patch Changes diff --git a/packages/onboarding-demo/package.json b/packages/onboarding-demo/package.json index 5e6fbf4b00..0ec0fc60da 100644 --- a/packages/onboarding-demo/package.json +++ b/packages/onboarding-demo/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/onboarding-demo", - "version": "0.0.4", + "version": "0.0.5", "private": true, "type": "module", "scripts": { diff --git a/packages/plugins/desktop-settings/CHANGELOG.md b/packages/plugins/desktop-settings/CHANGELOG.md index 38975d5802..95d45049af 100644 --- a/packages/plugins/desktop-settings/CHANGELOG.md +++ b/packages/plugins/desktop-settings/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-desktop-settings +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/desktop-settings/package.json b/packages/plugins/desktop-settings/package.json index aba4e2a3f5..3f927dc2c0 100644 --- a/packages/plugins/desktop-settings/package.json +++ b/packages/plugins/desktop-settings/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-desktop-settings", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/desktop-settings", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/encrypted-secrets/CHANGELOG.md b/packages/plugins/encrypted-secrets/CHANGELOG.md index 4fc7c36721..db01af7f82 100644 --- a/packages/plugins/encrypted-secrets/CHANGELOG.md +++ b/packages/plugins/encrypted-secrets/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-encrypted-secrets +## 0.0.52 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 0.0.51 ### Patch Changes diff --git a/packages/plugins/encrypted-secrets/package.json b/packages/plugins/encrypted-secrets/package.json index d47049b036..0499dd9136 100644 --- a/packages/plugins/encrypted-secrets/package.json +++ b/packages/plugins/encrypted-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-encrypted-secrets", - "version": "0.0.51", + "version": "0.0.52", "private": true, "type": "module", "exports": { diff --git a/packages/plugins/example/CHANGELOG.md b/packages/plugins/example/CHANGELOG.md index 84188a7a3e..ebef693e0e 100644 --- a/packages/plugins/example/CHANGELOG.md +++ b/packages/plugins/example/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-example +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/example/package.json b/packages/plugins/example/package.json index 61e2cc5f18..87b1e2a62a 100644 --- a/packages/plugins/example/package.json +++ b/packages/plugins/example/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-example", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/example", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/file-secrets/CHANGELOG.md b/packages/plugins/file-secrets/CHANGELOG.md index 4fb028a45f..5aa71cc0af 100644 --- a/packages/plugins/file-secrets/CHANGELOG.md +++ b/packages/plugins/file-secrets/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-file-secrets +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/file-secrets/package.json b/packages/plugins/file-secrets/package.json index dc783a98bd..2575cad448 100644 --- a/packages/plugins/file-secrets/package.json +++ b/packages/plugins/file-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-file-secrets", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/graphql/CHANGELOG.md b/packages/plugins/graphql/CHANGELOG.md index b063c836e2..22f48273fb 100644 --- a/packages/plugins/graphql/CHANGELOG.md +++ b/packages/plugins/graphql/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/plugin-graphql +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/config@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/react@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/graphql/package.json b/packages/plugins/graphql/package.json index c1de04eaa7..3aa148b426 100644 --- a/packages/plugins/graphql/package.json +++ b/packages/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-graphql", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/graphql", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/keychain/CHANGELOG.md b/packages/plugins/keychain/CHANGELOG.md index 68c8f47d50..e0c3dbd480 100644 --- a/packages/plugins/keychain/CHANGELOG.md +++ b/packages/plugins/keychain/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-keychain +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/keychain/package.json b/packages/plugins/keychain/package.json index d28235f142..c679b44b8d 100644 --- a/packages/plugins/keychain/package.json +++ b/packages/plugins/keychain/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-keychain", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/keychain", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/mcp/CHANGELOG.md b/packages/plugins/mcp/CHANGELOG.md index 54cc8691f5..956176cf99 100644 --- a/packages/plugins/mcp/CHANGELOG.md +++ b/packages/plugins/mcp/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/plugin-mcp +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/config@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/react@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index b10e9909d2..b070d53b60 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-mcp", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/mcp", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/mcp/src/sdk/catalog-sync.test.ts b/packages/plugins/mcp/src/sdk/catalog-sync.test.ts index f3328876b3..34ec9f24f8 100644 --- a/packages/plugins/mcp/src/sdk/catalog-sync.test.ts +++ b/packages/plugins/mcp/src/sdk/catalog-sync.test.ts @@ -14,7 +14,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Option, Ref, Schema } from "effect"; +import { Deferred, Effect, Fiber, Option, Ref, Schedule, Schema } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; import { @@ -133,11 +133,13 @@ describe("MCP tool-catalog sync (end-to-end)", () => { }), ); - it.effect("expired catalogs re-list on read once older than the freshness TTL", () => + // Live clock: the rebuild is real I/O against the test server, so the poll + // must advance on wall time rather than the test clock. + it.live("expired catalogs re-list in the background once older than the freshness TTL", () => Effect.gen(function* () { const mutable = makeMutableCatalogMcpServer(); const server = yield* serveMcpServer(mutable.factory); - // Everything is instantly stale — every tools read re-lists. + // Everything is instantly stale — every tools read starts a re-list. const executor = yield* makeCatalogTestExecutor(server.url, { toolsSyncTtlMs: 0 }); expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName); @@ -145,7 +147,17 @@ describe("MCP tool-catalog sync (end-to-end)", () => { // Server-side change with no notification and no executor signal at all. mutable.renameTool(); - const refreshed = toolNames(yield* executor.tools.list()); + // A time-expired catalog is stale-but-working: the read answers from the + // persisted rows without waiting on the upstream listing, and a later + // read observes the rebuilt catalog. + const refreshed = yield* executor.tools.list().pipe( + Effect.map(toolNames), + Effect.repeat({ + until: (names) => names.includes(mutable.renamedToolName), + schedule: Schedule.spaced("20 millis"), + }), + Effect.timeout("5 seconds"), + ); expect(refreshed).toContain(mutable.renamedToolName); expect(refreshed).not.toContain(mutable.initialToolName); }), diff --git a/packages/plugins/onepassword/CHANGELOG.md b/packages/plugins/onepassword/CHANGELOG.md index 5af3bed5d1..ddbeefa517 100644 --- a/packages/plugins/onepassword/CHANGELOG.md +++ b/packages/plugins/onepassword/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/plugin-onepassword +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/react@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/onepassword/package.json b/packages/plugins/onepassword/package.json index a52acd00df..7fcecff5e9 100644 --- a/packages/plugins/onepassword/package.json +++ b/packages/plugins/onepassword/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-onepassword", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/onepassword", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/openapi/CHANGELOG.md b/packages/plugins/openapi/CHANGELOG.md index 29dcdb2f4f..e16ed52c78 100644 --- a/packages/plugins/openapi/CHANGELOG.md +++ b/packages/plugins/openapi/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/plugin-openapi +## 1.6.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/config@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/react@1.4.73 + ## 1.6.9 ### Patch Changes diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 57e8d4320b..f9f527f0c9 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-openapi", - "version": "1.6.9", + "version": "1.6.10", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/openapi", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/provider-service-split/CHANGELOG.md b/packages/plugins/provider-service-split/CHANGELOG.md index 0cefad1526..346f7495a7 100644 --- a/packages/plugins/provider-service-split/CHANGELOG.md +++ b/packages/plugins/provider-service-split/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/plugin-provider-service-split +## 0.0.24 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/plugin-openapi@1.6.10 + ## 0.0.23 ### Patch Changes diff --git a/packages/plugins/provider-service-split/package.json b/packages/plugins/provider-service-split/package.json index a790fdea11..385f3cf685 100644 --- a/packages/plugins/provider-service-split/package.json +++ b/packages/plugins/provider-service-split/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-provider-service-split", - "version": "0.0.23", + "version": "0.0.24", "private": true, "type": "module", "exports": { diff --git a/packages/plugins/toolkits/CHANGELOG.md b/packages/plugins/toolkits/CHANGELOG.md index a4c8172a01..2df0df288c 100644 --- a/packages/plugins/toolkits/CHANGELOG.md +++ b/packages/plugins/toolkits/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/plugin-toolkits +## 1.5.45 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/api@1.4.73 + - @executor-js/react@1.4.73 + ## 1.5.44 ### Patch Changes diff --git a/packages/plugins/toolkits/package.json b/packages/plugins/toolkits/package.json index 275f8f3fcd..9de56ae469 100644 --- a/packages/plugins/toolkits/package.json +++ b/packages/plugins/toolkits/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-toolkits", - "version": "1.5.44", + "version": "1.5.45", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/toolkits", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/toolkits/src/server.test.ts b/packages/plugins/toolkits/src/server.test.ts index bab67eb0e9..3db165f6e6 100644 --- a/packages/plugins/toolkits/src/server.test.ts +++ b/packages/plugins/toolkits/src/server.test.ts @@ -132,6 +132,50 @@ describe("toolkitsPlugin", () => { }), ); + it.effect("prepares a dynamic scope from the toolkit's access patterns", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [toolkitsPlugin()] as const, + }); + + const orgKit = yield* executor.toolkits.create({ owner: "org", name: "Org Kit" }); + for (const pattern of [ + "github.org.main.*", + "slack.*", + "linear.*.*.issues.list", + "github.user.alice.*", + "executor.coreTools.*", + ]) { + yield* executor.toolkits.createConnection(orgKit.id, { pattern }); + } + const prepared = yield* executor.toolkits.preparePolicyResolverForSlug(orgKit.slug); + // An org toolkit never reaches personal rows: unowned prefixes pin to + // org, user-only prefixes drop, and static-only patterns contribute none. + const byIntegration = ( + a: { integration: string | null }, + b: { integration: string | null }, + ) => String(a.integration).localeCompare(String(b.integration)); + expect([...(prepared.dynamicScope ?? [])].sort(byIntegration)).toEqual([ + { integration: "github", owner: "org", connection: "main" }, + { integration: "linear", owner: "org", connection: null }, + { integration: "slack", owner: "org", connection: null }, + ]); + expect(prepared.resolve({ toolId: "github.org.main.repos.list" }).action).toBe("approve"); + expect(prepared.resolve({ toolId: "github.user.alice.repos.list" }).action).toBe("block"); + + const personalKit = yield* executor.toolkits.create({ owner: "user", name: "Me Kit" }); + yield* executor.toolkits.createConnection(personalKit.id, { pattern: "github.user.alice.*" }); + const personal = yield* executor.toolkits.preparePolicyResolverForSlug(personalKit.slug); + expect(personal.dynamicScope).toEqual([ + { integration: "github", owner: "user", connection: "alice" }, + ]); + + const missing = yield* executor.toolkits.preparePolicyResolverForSlug("no-such-kit"); + expect(missing.dynamicScope).toEqual([]); + expect(missing.resolve({ toolId: "github.org.main.repos.list" }).action).toBe("block"); + }), + ); + it.effect("treats a persisted connection-root approve as an access policy", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ diff --git a/packages/plugins/toolkits/src/server.ts b/packages/plugins/toolkits/src/server.ts index 7dacc3e456..eef15de797 100644 --- a/packages/plugins/toolkits/src/server.ts +++ b/packages/plugins/toolkits/src/server.ts @@ -2,16 +2,19 @@ import { Context, definePlugin, definePluginStorageCollection, + dynamicToolScopeForPattern, Effect, HttpApiBuilder, isValidPattern, matchPattern, Schema, + type DynamicToolScope, type EffectivePolicy, type Owner, type PluginCtx, type PluginStorageFacade, type PluginStorageCollectionFacade, + type PreparedToolPolicy, type StorageFailure, type ToolPolicyAction, type ToolPolicyProvider, @@ -150,22 +153,42 @@ const isLegacyConnectionPolicy = (policy: ToolkitPolicyRecord): boolean => { return parts.at(-1) === "*" && (parts.length === 3 || parts.length === 4); }; -const resolveToolkitPolicy = ( - toolId: string, +// The toolkit's rules, digested once so resolving a tool is a scan over +// already-sorted patterns. A tools list resolves every candidate row against +// the same snapshot, so the legacy split and the sort must not be redone per +// tool. +interface ToolkitRuleSnapshot { + /** Patterns granting access: connection records plus legacy approve rows. */ + readonly accessPatterns: readonly string[]; + /** Non-legacy policies in precedence order. */ + readonly orderedPolicies: readonly ToolkitPolicyRecord[]; +} + +const digestToolkitRules = ( connections: readonly ToolkitConnectionRecord[], policies: readonly ToolkitPolicyRecord[], +): ToolkitRuleSnapshot => { + const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections); + return { + accessPatterns: [ + ...connections.map((connection) => connection.pattern), + ...policies.filter((policy) => legacyPolicyIds.has(policy.id)).map((p) => p.pattern), + ], + orderedPolicies: policies + .filter((policy) => !legacyPolicyIds.has(policy.id)) + .sort(comparePositioned), + }; +}; + +const resolveToolkitPolicy = ( + toolId: string, + rules: ToolkitRuleSnapshot, defaultRequiresApproval?: boolean, ): EffectivePolicy => { - const legacyPolicyIds = legacyConnectionPolicyIds(policies, connections); - const connected = - connections.some((connection) => matchPattern(connection.pattern, toolId)) || - policies.some( - (policy) => legacyPolicyIds.has(policy.id) && matchPattern(policy.pattern, toolId), - ); + const connected = rules.accessPatterns.some((pattern) => matchPattern(pattern, toolId)); if (!connected) return blockedPolicy(); - for (const policy of [...policies].sort(comparePositioned)) { - if (legacyPolicyIds.has(policy.id)) continue; + for (const policy of rules.orderedPolicies) { if (!matchPattern(policy.pattern, toolId)) continue; return { action: policy.action, @@ -177,6 +200,28 @@ const resolveToolkitPolicy = ( return pluginDefaultPolicy(defaultRequiresApproval); }; +// The dynamic-tool prefixes the access patterns can reach. An org toolkit +// never grants personal tools, so its prefixes are pinned to org rows and +// user-only prefixes drop out; the per-tool check still enforces the same +// rule for anything the prefix cannot express. +const toolkitDynamicScope = ( + rules: ToolkitRuleSnapshot, + isOrg: boolean, +): readonly DynamicToolScope[] => { + const scopes: DynamicToolScope[] = []; + for (const pattern of rules.accessPatterns) { + const scope = dynamicToolScopeForPattern(pattern); + if (!scope) continue; + if (!isOrg) { + scopes.push(scope); + continue; + } + if (scope.owner === "user") continue; + scopes.push(scope.owner === null ? { ...scope, owner: "org" } : scope); + } + return scopes; +}; + const legacyConnectionPolicyIds = ( policies: readonly ToolkitPolicyRecord[], connections: readonly ToolkitConnectionRecord[], @@ -507,38 +552,36 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { if (toolkit.owner === "org" && isPersonalDynamicToolId(toolId)) return blockedPolicy(); const policies = yield* listPoliciesForRecord(toolkit.data.id); const connections = yield* listConnectionsForRecord(toolkit.data.id); - return resolveToolkitPolicy(toolId, connections, policies, defaultRequiresApproval); + return resolveToolkitPolicy( + toolId, + digestToolkitRules(connections, policies), + defaultRequiresApproval, + ); }); // Batched form of `resolvePolicyForSlug`: fetch the toolkit, its policies, and // its connections ONCE, then hand back a pure resolver core can run for every - // tool in a single tools/list or tools/call. `resolvePolicyForSlug` re-fetches - // policies + connections on every tool, which is the per-tool N+1 that scales - // with the whole catalog on the list surface. This is byte-for-byte the same - // resolution, just hoisted out of the loop. + // tool in a single tools/list or tools/call, plus the prefixes those rules + // can reach so core reads only the toolkit's rows instead of the whole + // catalog. `resolvePolicyForSlug` re-fetches policies + connections on every + // tool, which is the per-tool N+1 that scales with the whole catalog on the + // list surface. This is the same resolution, hoisted out of the loop. const preparePolicyResolverForSlug = ( slug: string, - ): Effect.Effect< - (input: { - readonly toolId: string; - readonly defaultRequiresApproval?: boolean; - }) => EffectivePolicy, - StorageFailure - > => + ): Effect.Effect => Effect.gen(function* () { const toolkit = yield* getBySlugEntry(slug); - if (!toolkit) return () => blockedPolicy(); + if (!toolkit) return { resolve: () => blockedPolicy(), dynamicScope: [] }; const isOrg = toolkit.owner === "org"; const policies = yield* listPoliciesForRecord(toolkit.data.id); const connections = yield* listConnectionsForRecord(toolkit.data.id); - return (input: { readonly toolId: string; readonly defaultRequiresApproval?: boolean }) => { - if (isOrg && isPersonalDynamicToolId(input.toolId)) return blockedPolicy(); - return resolveToolkitPolicy( - input.toolId, - connections, - policies, - input.defaultRequiresApproval, - ); + const rules = digestToolkitRules(connections, policies); + return { + resolve: (input) => { + if (isOrg && isPersonalDynamicToolId(input.toolId)) return blockedPolicy(); + return resolveToolkitPolicy(input.toolId, rules, input.defaultRequiresApproval); + }, + dynamicScope: toolkitDynamicScope(rules, isOrg), }; }); diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 6239d80c2c..4c3a7df603 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/react +## 1.4.73 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.10 + - @executor-js/api@1.4.73 + ## 1.4.72 ### Patch Changes diff --git a/packages/react/package.json b/packages/react/package.json index bc7e72a21a..62e41b5c4b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/react", - "version": "1.4.72", + "version": "1.4.73", "private": true, "type": "module", "exports": { diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 0d6fb9af7d..366cd52994 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -17,6 +17,7 @@ import { } from "@executor-js/sdk/shared"; import * as Atom from "effect/unstable/reactivity/Atom"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import * as Effect from "effect/Effect"; import { ExecutorApiClient } from "./client"; @@ -198,6 +199,37 @@ export const refreshConnection = ExecutorApiClient.mutation("connections", "refr * cache on every load). */ export const checkConnectionHealth = ExecutorApiClient.mutation("connections", "checkHealth"); +export interface CheckConnectionHealthArgs { + readonly params: { + readonly owner: Owner; + readonly integration: IntegrationSlug; + readonly name: ConnectionName; + }; + readonly query: { readonly ifStaleMs?: number }; + readonly reactivityKeys?: ReadonlyArray; +} + +/** The AUTOMATIC health probe, one atom PER CONNECTION. + * + * `checkConnectionHealth` above is one shared mutation atom. Awaiting it + * (`useAtomSet(..., { mode: "promiseExit" })`) resolves with the atom's next + * settled result, whichever call produced it, and a new call interrupts the + * one in flight. A surface that probes every row of a list in one pass + * therefore cancels all but the last probe and hands every row the LAST + * row's verdict. Each row then reads a foreign verdict as a change to its own + * connection, refreshes the connections cache, and re-probes: the probe storm + * the automatic path was built to avoid. Keying the atom by connection address + * gives every probe its own fiber and its own result. */ +export const checkConnectionHealthFor = Atom.family((address: ConnectionAddress) => + ExecutorApiClient.runtime.fn()((args) => { + const probe = Effect.gen(function* () { + const client = yield* ExecutorApiClient; + return yield* client.connections.checkHealth({ params: args.params, query: args.query }); + }).pipe(Effect.withSpan("connection.health.probe", { attributes: { address } })); + return args.reactivityKeys ? Reactivity.mutation(probe, args.reactivityKeys) : probe; + }), +); + /** Validate an IN-FLIGHT credential without saving it (the key-first connect * flow). Returns the probe result the UI derives a connection name from. */ export const validateConnection = ExecutorApiClient.mutation("connections", "validate"); diff --git a/packages/react/src/api/error-reporting.test.ts b/packages/react/src/api/error-reporting.test.ts index 655f792ba7..c1c1d09e97 100644 --- a/packages/react/src/api/error-reporting.test.ts +++ b/packages/react/src/api/error-reporting.test.ts @@ -53,6 +53,20 @@ describe("frontend error reporting", () => { expect(messageFromExit(Exit.fail({ reason: "unknown" }), "Fallback")).toBe("Fallback"); }); + it("reads a message the error exposes as a prototype getter", () => { + // Schema-tagged API errors (e.g. OrgWriteDeniedError) declare no `message` + // field; the sentence lives on a class getter, which a struct decode misses. + class GetterError extends Data.TaggedError("GetterError")<{}> { + override get message(): string { + return "Requires a workspace admin."; + } + } + const exit = Exit.fail(new GetterError()); + + expect(messageFromExit(exit, "Fallback")).toBe("Requires a workspace admin."); + expect(messageFromUnknown(new GetterError(), "Fallback")).toBe("Requires a workspace admin."); + }); + it("reports failed exits with the provided context", () => { const exit = Exit.fail({ message: "Could not update integration" }); const { calls, report } = captureReports(); diff --git a/packages/react/src/api/error-reporting.tsx b/packages/react/src/api/error-reporting.tsx index 8a9eab0d03..2c19b8ce39 100644 --- a/packages/react/src/api/error-reporting.tsx +++ b/packages/react/src/api/error-reporting.tsx @@ -21,8 +21,17 @@ class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{ readonly context: FrontendErrorContext; }> {} -const ErrorMessage = Schema.Struct({ message: Schema.String }); -const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); +// Effect's tagged error classes (`Schema.TaggedErrorClass`) often declare no +// `message` field and expose it as a prototype getter instead, so a struct +// decode — which only sees own properties — would miss the very sentence the +// server wrote for the user. Read the property directly. +const decodeErrorMessage = (value: unknown): Option.Option<{ readonly message: string }> => { + if (typeof value !== "object" || value === null) return Option.none(); + const message: unknown = Reflect.get(value, "message"); + return typeof message === "string" && message.length > 0 + ? Option.some({ message }) + : Option.none(); +}; const TaggedValue = Schema.Struct({ _tag: Schema.String }); const decodeTaggedValue = Schema.decodeUnknownOption(TaggedValue); diff --git a/packages/react/src/components/add-account-modal.test.ts b/packages/react/src/components/add-account-modal.test.ts index 0fdedf4983..90558590e6 100644 --- a/packages/react/src/components/add-account-modal.test.ts +++ b/packages/react/src/components/add-account-modal.test.ts @@ -11,6 +11,7 @@ import { import type { AuthMethod } from "../lib/auth-placements"; import type { OAuthPopupReservation } from "../plugins/oauth-sign-in"; +import type { OAuthClientOption } from "../plugins/use-effective-oauth-client"; import { connectionNameFrom, connectionLabel, @@ -19,6 +20,7 @@ import { DEFAULT_CONNECTION_OWNER, hasDcr, mergeCustomMethods, + preferredMethodId, oauthIdentityLabelFromHealth, runAutomaticOAuthConnect, runCimdConnect, @@ -1438,3 +1440,68 @@ describe("runDcrConnect", () => { expect(String(registerArgs!.slug)).toBe("dcr-auth-example-com"); }); }); + +describe("preferredMethodId", () => { + const integration = IntegrationSlug.make("team-chat"); + const token = apiKeyMethod("token", "spec"); + const oauth: AuthMethod = { + id: "oauth", + label: "OAuth", + kind: "oauth", + source: "spec", + template: AuthTemplateSlug.make("oauth"), + placements: [], + oauth: { tokenUrl: "https://auth.example.com/token", scopes: ["read"] }, + }; + const client: OAuthClientOption = { + owner: "org", + slug: OAuthClientSlug.make("team-chat-app"), + grant: "authorization_code", + authorizationUrl: "https://auth.example.com/authorize", + tokenUrl: "https://auth.example.com/token", + clientId: "synthetic-client", + origin: { kind: "manual", integration: null }, + }; + + it("prefers OAuth only with a client matched to that method", () => { + expect(preferredMethodId([token, oauth], [client], integration)).toBe("oauth"); + expect(preferredMethodId([oauth, token], [], integration)).toBe("token"); + expect( + preferredMethodId( + [token, oauth], + [{ ...client, tokenUrl: "https://other.example.com/token" }], + integration, + ), + ).toBe("token"); + }); + + it("selects the OAuth method with a client rather than the first OAuth method", () => { + const unconfigured = { + ...oauth, + id: "other-oauth", + oauth: { tokenUrl: "https://unregistered.example.net/token" }, + }; + expect(preferredMethodId([token, unconfigured, oauth], [client], integration)).toBe("oauth"); + }); + + it("uses matching built-in clients only when they allow the requested scopes", () => { + const builtIn: OAuthClientOption = { + ...client, + origin: { kind: "first_party", allowedScopes: ["read"] }, + }; + expect(preferredMethodId([token, oauth], [builtIn], integration)).toBe("oauth"); + expect( + preferredMethodId( + [token, { ...oauth, oauth: { ...oauth.oauth, scopes: ["write"] } }], + [builtIn], + integration, + ), + ).toBe("token"); + }); + + it("keeps single-method and empty integrations usable", () => { + expect(preferredMethodId([token], [], integration)).toBe("token"); + expect(preferredMethodId([oauth], [], integration)).toBe("oauth"); + expect(preferredMethodId([], [], integration)).toBe(""); + }); +}); diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 5e10fd509f..222dd0f682 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -73,6 +73,7 @@ import { clientDisplayName, clientHost, optimisticDcrClientSlug, + selectClientsForEndpoints, selectDcrClientsForIntegration, uniqueClientSlug, useOAuthClientsForIntegration, @@ -628,13 +629,29 @@ export const connectionExistsMessage = (label: string): string => * explicit choice. Personal: a connection is most often a personal credential. */ export const DEFAULT_CONNECTION_OWNER: Owner = "user"; -/** The method the modal opens on. OAuth needs a registered app (or a DCR - * round-trip) before "Connect" does anything; a key is one paste. When an - * integration declares both, starting on OAuth greets most users with - * "Register app" — a dead end — while the working method sits one tab over. - * Prefer the first non-OAuth method; OAuth stays one click away. */ -export const preferredMethodId = (methods: readonly AuthMethod[]): string => - (methods.find((method) => method.kind !== "oauth") ?? methods[0])?.id ?? ""; +/** Prefer OAuth only when its picker has a matching, usable client. Otherwise + * prefer a credential method; OAuth-only integrations still expose setup. */ +export const preferredMethodId = ( + methods: readonly AuthMethod[], + clients: readonly OAuthClientOption[], + integration: IntegrationSlug, +): string => + ( + methods.find( + (method) => + method.kind === "oauth" && + selectClientsForEndpoints(clients, { + integration, + tokenUrl: method.oauth?.tokenUrl, + authorizationUrl: method.oauth?.authorizationUrl, + scopes: method.oauth?.scopes, + discoversScopes: hasDcr(method), + requireEndpointMatch: true, + }).matched.length > 0, + ) ?? + methods.find((method) => method.kind !== "oauth") ?? + methods[0] + )?.id ?? ""; const authMethodKey = (method: AuthMethod): string => method.source === "custom" ? `custom:${String(method.template)}` : `declared:${method.id}`; @@ -1443,7 +1460,9 @@ function AddAccountModalView(props: AddAccountModalProps) { ); const [addingMethod, setAddingMethod] = useState(false); - const [methodId, setMethodId] = useState(preferredMethodId(methods)); + // An untouched form follows client availability. User interaction or a + // handoff pins a method so a late clients response cannot replace their form. + const [selectedMethodId, setMethodId] = useState(null); // One value per distinct credential input (`variable → pasted value`). A // single-secret method has just `{ token }`; a method with two distinct inputs // (e.g. Datadog's two keys) collects one value per variable. @@ -1548,6 +1567,23 @@ function AddAccountModalView(props: AddAccountModalProps) { () => (AsyncResult.isSuccess(allClientsResult) ? allClientsResult.value : []), [allClientsResult], ); + const defaultMethodId = useMemo( + () => + preferredMethodId( + allMethods, + clientSummaries.flatMap((client) => + client.grant === "authorization_code" || client.grant === "client_credentials" + ? [{ ...client, grant: client.grant }] + : [], + ), + integration, + ), + [allMethods, clientSummaries, integration], + ); + const methodId = + selectedMethodId !== null && allMethods.some((method) => method.id === selectedMethodId) + ? selectedMethodId + : defaultMethodId; const usage = useMemo( () => buildUsageMap(AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []), [connectionsResult], @@ -1582,15 +1618,6 @@ function AddAccountModalView(props: AddAccountModalProps) { [allMethods, methodId], ); - useEffect(() => { - if (allMethods.length === 0) { - if (methodId !== "") setMethodId(""); - return; - } - if (allMethods.some((m: AuthMethod) => m.id === methodId)) return; - setMethodId(allMethods[0]!.id); - }, [allMethods, methodId]); - // Apply the handoff prefill ONCE per handoff key (tracked by ref). The // effect's deps include `allMethods`, which gets a new identity whenever the // integration refetches — and the wizard itself triggers a refetch mid-flow @@ -1622,18 +1649,6 @@ function AddAccountModalView(props: AddAccountModalProps) { setDcrFallbackMessage(null); }, [initialState, allMethods, defaultOwner, ownerOptions]); - useEffect(() => { - if (allMethods.length === 0) return; - if (allMethods.some((m: AuthMethod) => m.id === methodId)) return; - const initialMethod = initialState?.template - ? allMethods.find( - (m: AuthMethod) => - m.id === initialState.template || String(m.template) === initialState.template, - ) - : undefined; - setMethodId(initialMethod?.id ?? preferredMethodId(allMethods)); - }, [allMethods, initialState?.template, methodId]); - // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link. // The agent fills in the endpoints/grant/client id it discovered; the client // secret is deliberately absent and is typed by the human in the form below. @@ -2695,6 +2710,9 @@ function AddAccountModalView(props: AddAccountModalProps) { setMethodId(methodId)} + onKeyDownCapture={() => setMethodId(methodId)} + onInput={() => setMethodId(methodId)} className={cn( "max-h-[85vh] overflow-x-hidden overflow-y-auto", (addingMethod && createCustomMethod) || oauthRegistering || oauthEditing diff --git a/packages/react/src/components/command-palette.tsx b/packages/react/src/components/command-palette.tsx index 585ceed1f2..ecd148dcb2 100644 --- a/packages/react/src/components/command-palette.tsx +++ b/packages/react/src/components/command-palette.tsx @@ -9,6 +9,7 @@ import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favi import { PresetIcon } from "./preset-icon"; import { integrationsOptimisticAtom } from "../api/atoms"; import { useIntegrationPlugins } from "@executor-js/sdk/client"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import { CommandDialog, CommandEmpty, @@ -34,6 +35,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool const integrationPlugins = useIntegrationPlugins(); const navigate = useNavigate(); const integrationsResult = useAtomValue(integrationsOptimisticAtom); + const canCreateIntegration = useCanCreateWorkspaceConnections(); // Toggle with ⌘K / Ctrl+K useEffect(() => { @@ -176,11 +178,13 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool {integrationPlugins.map((plugin) => ( goToAdd(plugin.key)} > Add {plugin.label} + {!canCreateIntegration && Admin only} ))} @@ -193,6 +197,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool {presetEntries.map((e) => ( goToPreset(e.pluginKey, e.presetId, e.presetUrl)} > @@ -208,7 +213,9 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool } /> {e.presetName} - {e.pluginLabel} + + {canCreateIntegration ? e.pluginLabel : "Admin only"} + ))} diff --git a/packages/react/src/components/integration-creation-gate.tsx b/packages/react/src/components/integration-creation-gate.tsx new file mode 100644 index 0000000000..afcdee7025 --- /dev/null +++ b/packages/react/src/components/integration-creation-gate.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; + +import { orgMembersAtom } from "../api/account-atoms"; +import { isAsyncResultLoading } from "../lib/async-result"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; +import { Button } from "./button"; +import { PageContainer, PageHeader } from "./page"; +import { Skeleton } from "./skeleton"; + +/** Keep integration creation flows behind the same role gate as edit and delete. */ +export function IntegrationCreationGate({ children }: { readonly children: ReactNode }) { + const canCreate = useCanCreateWorkspaceConnections(); + const members = useAtomValue(orgMembersAtom); + if (canCreate) return children; + if (isAsyncResultLoading(members)) { + return ( + + + + ); + } + + return ( + + + + + ); +} diff --git a/packages/react/src/components/tool-tree.tsx b/packages/react/src/components/tool-tree.tsx index f0fa20e6f2..d3b8c94908 100644 --- a/packages/react/src/components/tool-tree.tsx +++ b/packages/react/src/components/tool-tree.tsx @@ -3,7 +3,7 @@ import { ChevronRightIcon, MoreHorizontalIcon, SearchIcon, XIcon } from "lucide- import type { EffectivePolicy, Owner, ToolPolicyAction } from "@executor-js/sdk/shared"; import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; import { trackEvent } from "../api/analytics"; -import { toPolicyPattern } from "../lib/policy-pattern"; +import { accountPolicyPattern, toPolicyPattern } from "../lib/policy-pattern"; import { Badge } from "./badge"; import { Button } from "./button"; import { Input } from "./input"; @@ -297,7 +297,9 @@ export function ToolTree(props: { * emit the tool's full dotted id; group rows emit `prefix.*`. */ onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; onClearPolicy?: (pattern: string) => void; - /** Maps the displayed row path into the persisted policy pattern. */ + /** Maps the displayed row path into the persisted policy pattern for the + * flat tree. Ignored in the account-grouped view, where every row writes a + * pattern pinned to its own account (`integration...`). */ patternForDisplay?: (displayPattern: string) => string; /** Sorted user-authored policies (most-precedent first). Used to * decide whether a node has its own exact-pattern user rule today @@ -429,26 +431,55 @@ export function ToolTree(props: { : (props.emptyLabel ?? "No tools available")}
) : groupByConnection ? ( - accountGroups.map((group) => ( -
-
- {ownerDisplay.showOwnerLabels ? ( - - {ownerLabel(group.owner)} - - ) : null} - - {group.integration && group.connection - ? `${group.integration} / ${group.connection}` - : group.connection || ownerDisplay.label(group.owner)} - - - {group.tools.length} - -
- -
- )) + accountGroups.map((group) => { + // Rows inside an account section author rules for THAT account + // only: two connections of one integration are different + // credentials, so a rule set under one must not govern the other. + const accountPattern = accountPolicyPattern(group.owner, group.connection); + const wholeAccountPattern = accountPattern(`${group.integration}.*`); + const wholeAccountRule = exactPatterns.get(wholeAccountPattern); + const accountLabel = + group.integration && group.connection + ? `${group.integration} / ${group.connection}` + : group.connection || ownerDisplay.label(group.owner); + return ( +
+
+ {ownerDisplay.showOwnerLabels ? ( + + {ownerLabel(group.owner)} + + ) : null} + + {accountLabel} + + + {group.tools.length} + + {onSetPolicy && group.integration && group.connection ? ( + + ) : null} +
+ +
+ ); + }) ) : ( )} diff --git a/packages/react/src/components/workspace-admin-hint.tsx b/packages/react/src/components/workspace-admin-hint.tsx new file mode 100644 index 0000000000..ea497615f0 --- /dev/null +++ b/packages/react/src/components/workspace-admin-hint.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"; + +/** Explain a disabled workspace action on hover or keyboard focus. */ +export function WorkspaceAdminHint(props: { + readonly allowed: boolean; + readonly children: ReactNode; +}) { + if (props.allowed) return props.children; + return ( + + + + + {props.children} + + + + Requires a workspace admin + + + + ); +} diff --git a/packages/react/src/lib/integration-add.tsx b/packages/react/src/lib/integration-add.tsx index 1b1ddbac96..968bc0f3f9 100644 --- a/packages/react/src/lib/integration-add.tsx +++ b/packages/react/src/lib/integration-add.tsx @@ -11,20 +11,14 @@ import { Link } from "@tanstack/react-router"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; -import * as Schema from "effect/Schema"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { integrationsOptimisticAtom } from "../api/atoms"; - -const ErrorMessage = Schema.Struct({ message: Schema.String }); -const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); +import { messageFromExit } from "../api/error-reporting"; /** The failed Exit's `message`, or `fallback` when the error carries none. */ -export const errorMessageFromExit = (exit: Exit.Exit, fallback: string): string => - Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { - onNone: () => fallback, - onSome: ({ message }) => message, - }); +export const errorMessageFromExit: (exit: Exit.Exit, fallback: string) => string = + messageFromExit; export const isIntegrationAlreadyExistsExit = (exit: Exit.Exit): boolean => Option.match(Exit.findErrorOption(exit), { diff --git a/packages/react/src/lib/policy-pattern.test.ts b/packages/react/src/lib/policy-pattern.test.ts new file mode 100644 index 0000000000..0c3604eb0a --- /dev/null +++ b/packages/react/src/lib/policy-pattern.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { accountPolicyPattern, toPolicyPattern } from "./policy-pattern"; + +describe("policy pattern bridges", () => { + it("wildcards owner and connection for the connection-agnostic tree", () => { + expect(toPolicyPattern("slack.conversations.history")).toBe("slack.*.*.conversations.history"); + expect(toPolicyPattern("slack.conversations.*")).toBe("slack.*.*.conversations.*"); + expect(toPolicyPattern("slack.*")).toBe("slack.*"); + expect(toPolicyPattern("*")).toBe("*"); + }); + + it("pins owner and connection for a row inside an account section", () => { + const forBot = accountPolicyPattern("org", "bot"); + expect(forBot("slack.conversations.history")).toBe("slack.org.bot.conversations.history"); + expect(forBot("slack.conversations.*")).toBe("slack.org.bot.conversations.*"); + expect(forBot("slack.*")).toBe("slack.org.bot.*"); + expect(forBot("slack")).toBe("slack.org.bot.*"); + expect(forBot("*")).toBe("*"); + }); +}); diff --git a/packages/react/src/lib/policy-pattern.ts b/packages/react/src/lib/policy-pattern.ts index 7a274f4296..c2727f38b4 100644 --- a/packages/react/src/lib/policy-pattern.ts +++ b/packages/react/src/lib/policy-pattern.ts @@ -1,4 +1,4 @@ -import { matchPattern } from "@executor-js/sdk/shared"; +import { matchPattern, type Owner } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // Policy pattern bridge. @@ -29,3 +29,29 @@ export const toPolicyPattern = (displayPattern: string): string => { }; export { matchPattern }; + +// --------------------------------------------------------------------------- +// Account-scoped bridge. +// +// The account-grouped Tools tab shows the same tool once per connection. A +// rule authored from a row inside one account's section must govern THAT +// account only — a Slack bot connection and a Slack user connection are +// different credentials with different capabilities, and blocking a tool on +// one must not silently block it on the other. So instead of wildcarding the +// owner + connection segments, fill them in: `integration...`. +// `integration.*` becomes the whole-account subtree +// `integration...*`. Apply the returned mapper at both the site +// that BUILDS a pattern and the site that LOOKS UP the exact rule, exactly as +// with `toPolicyPattern`. +// --------------------------------------------------------------------------- + +export const accountPolicyPattern = + (owner: Owner, connection: string) => + (displayPattern: string): string => { + if (displayPattern === "*") return "*"; + const firstDot = displayPattern.indexOf("."); + if (firstDot === -1) return `${displayPattern}.${owner}.${connection}.*`; + const integration = displayPattern.slice(0, firstDot); + const rest = displayPattern.slice(firstDot + 1); + return `${integration}.${owner}.${connection}.${rest}`; + }; diff --git a/packages/react/src/lib/use-connection-health.ts b/packages/react/src/lib/use-connection-health.ts index 9bfc1fe36a..52100b5fed 100644 --- a/packages/react/src/lib/use-connection-health.ts +++ b/packages/react/src/lib/use-connection-health.ts @@ -7,11 +7,17 @@ // drift apart. import { useCallback, useContext, useEffect, useRef, useState } from "react"; -import { RegistryContext, useAtomSet } from "@effect/atom-react"; +import { RegistryContext } from "@effect/atom-react"; +import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; import type { Connection, HealthCheckResult, HealthStatus, Owner } from "@executor-js/sdk/shared"; -import { checkConnectionHealth, connectionsOptimisticAtom } from "../api/atoms"; +import { + checkConnectionHealthFor, + connectionsOptimisticAtom, + type CheckConnectionHealthArgs, +} from "../api/atoms"; import { connectionCheckKeys } from "../api/reactivity-keys"; /** Freshness window for automatic revalidation: a HEALTHY verdict younger @@ -97,6 +103,32 @@ function useInvalidateConnections(): (owner: Owner) => void { ); } +/** + * Run one probe against its OWN per-connection atom and await that atom's + * result. The shared `checkConnectionHealth` mutation cannot be awaited from a + * loop: every `set` interrupts the previous call and every waiter resolves + * with whichever call settled last, so a list of N rows would hand N-1 rows a + * verdict for a connection that is not theirs (see `checkConnectionHealthFor`). + * This is the same set-then-await that `useAtomSet` performs in promise mode, + * addressed at the connection's atom, and usable from a loop. + */ +function useProbeConnection(): ( + connection: Connection, + args: CheckConnectionHealthArgs, +) => Promise> { + const registry = useContext(RegistryContext); + return useCallback( + (connection: Connection, args: CheckConnectionHealthArgs) => { + const atom = checkConnectionHealthFor(connection.address); + registry.set(atom, args); + return Effect.runPromiseExit( + AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }), + ); + }, + [registry], + ); +} + /** * Health for ONE connection, stale-while-revalidate. The persisted verdict * renders instantly; a background probe on mount corrects it in place (once @@ -112,7 +144,7 @@ export function useConnectionHealth(connection: Connection): { // A live probe result, once a check has run; merged with the persisted // verdict by freshness (see freshestVerdict for why not live-always-wins). const [liveProbe, setLiveProbe] = useState(null); - const doCheck = useAtomSet(checkConnectionHealth, { mode: "promiseExit" }); + const doCheck = useProbeConnection(); const invalidateConnections = useInvalidateConnections(); const probe = freshestVerdict(liveProbe, connection.lastHealth); @@ -137,7 +169,7 @@ export function useConnectionHealth(connection: Connection): { seenEpoch.current = epoch; if (!firstSight && !cleared) return; if (healthyAndFresh(last)) return; - void doCheck({ + void doCheck(connection, { params: connectionParams(connection), query: revalidateQuery(last), }).then((exit) => { @@ -160,7 +192,7 @@ export function useConnectionHealth(connection: Connection): { // Manual "Check now": invalidate the connections cache unconditionally so // every surface picks up the freshly persisted verdict. Adopting the // result's epoch keeps the resulting refetch from re-probing. - const exit = await doCheck({ + const exit = await doCheck(connection, { params: connectionParams(connection), query: {}, reactivityKeys: connectionCheckKeys, @@ -190,7 +222,7 @@ export function useConnectionsHealth( connections: readonly Connection[], ): (connection: Connection) => HealthCheckResult | null { const [liveProbes, setLiveProbes] = useState>(new Map()); - const doCheck = useAtomSet(checkConnectionHealth, { mode: "promiseExit" }); + const doCheck = useProbeConnection(); const invalidateConnections = useInvalidateConnections(); // Once per VERDICT per connection (same epoch guard as the single-connection @@ -206,7 +238,7 @@ export function useConnectionsHealth( if (revalidated.current.has(key) && revalidated.current.get(key) === epoch) continue; revalidated.current.set(key, epoch); if (healthyAndFresh(last)) continue; - void doCheck({ + void doCheck(connection, { params: connectionParams(connection), query: revalidateQuery(last), }).then((exit) => { diff --git a/packages/react/src/multiplayer/shell.tsx b/packages/react/src/multiplayer/shell.tsx index 1bd109ece1..2ee203b25e 100644 --- a/packages/react/src/multiplayer/shell.tsx +++ b/packages/react/src/multiplayer/shell.tsx @@ -6,6 +6,7 @@ import { BookOpen, Command, ExternalLink, PlusIcon } from "lucide-react"; import type { Integration } from "@executor-js/sdk/shared"; import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; +import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; import { Button } from "../components/button"; import { Skeleton } from "../components/skeleton"; import { SidebarUpdateCard } from "../components/update-card"; @@ -25,6 +26,7 @@ import { CommandPalette } from "../components/command-palette"; import { Wordmark } from "../components/wordmark"; import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; import { useAuth } from "./auth-context"; +import { useCanCreateWorkspaceConnections } from "./use-admin-nav"; // --------------------------------------------------------------------------- // Shared multiplayer shell (cloud + self-host). @@ -351,6 +353,7 @@ function SidebarContent( }, ) { const plugins = useClientPlugins(); + const canCreateIntegration = useCanCreateWorkspaceConnections(); const pluginNavItems = plugins.flatMap((plugin) => (plugin.pages ?? []).flatMap((page) => page.nav @@ -385,17 +388,20 @@ function SidebarContent(
Integrations - + + +
diff --git a/packages/react/src/pages/integration-add.tsx b/packages/react/src/pages/integration-add.tsx index 9691ca4cd2..f6b1f32a72 100644 --- a/packages/react/src/pages/integration-add.tsx +++ b/packages/react/src/pages/integration-add.tsx @@ -1,16 +1,27 @@ -import { Suspense } from "react"; +import { Suspense, type ComponentProps } from "react"; import { useAtomRefresh } from "@effect/atom-react"; import { Link, useNavigate } from "@tanstack/react-router"; import { useIntegrationPlugins } from "@executor-js/sdk/client"; import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { useExecutorDocumentTitle } from "../lib/document-title"; +import { IntegrationCreationGate } from "../components/integration-creation-gate"; // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- -export function AddIntegrationPage(props: { +/** Render an integration setup flow only when the workspace role permits creation. */ +export function AddIntegrationPage(props: ComponentProps) { + useExecutorDocumentTitle("Add integration"); + return ( + + + + ); +} + +function AddIntegrationContent(props: { pluginKey: string; url?: string; preset?: string; @@ -20,7 +31,6 @@ export function AddIntegrationPage(props: { authKind?: string; specOverrides?: string; }) { - useExecutorDocumentTitle("Add integration"); const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } = props; const navigate = useNavigate(); diff --git a/packages/react/src/pages/integration-browse.tsx b/packages/react/src/pages/integration-browse.tsx index 83b133e28f..c63789d3ef 100644 --- a/packages/react/src/pages/integration-browse.tsx +++ b/packages/react/src/pages/integration-browse.tsx @@ -27,6 +27,7 @@ import { } from "../components/integration-favicon"; import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import { availableCatalogKinds, catalogLogoUrl, @@ -244,7 +245,7 @@ function RowIcon(props: { readonly src?: string; readonly alt: string }) { ); } -function ResultCard(props: { readonly row: Row }) { +function ResultCard(props: { readonly row: Row; readonly canCreate: boolean }) { const { row } = props; return (
+ {!canCreate && ( +

+ Requires a workspace admin to add integrations. +

+ )}
{ - if (event.key === "Enter" && isUrl) void handleDetect(); + if (event.key === "Enter" && isUrl && canCreate) void handleDetect(); }} placeholder="Search integrations, or paste a URL…" aria-label="Search integrations, or paste a URL" @@ -951,7 +960,7 @@ export function IntegrationBrowsePage() { + + + )} {canRefresh && ( @@ -530,20 +551,23 @@ export function IntegrationDetailPage(props: { variant="destructive" size="sm" onClick={() => void handleDelete()} - disabled={deleting} + disabled={deleting || !canMutateIntegration} > {deleting ? "Deleting..." : "Confirm Delete"}
) : ( - + + + ))}
@@ -609,8 +633,8 @@ export function IntegrationDetailPage(props: { tools={integrationTools} selectedToolId={selectedToolId} onSelect={setSelectedToolId} - onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} - onClearPolicy={(pattern) => void policyActions.clear(pattern)} + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} policies={sortedPolicies} groupByConnection={!isBuiltInIntegration} emptyLabel={hasToolSyncIssue ? emptyToolsTitle : undefined} @@ -625,9 +649,15 @@ export function IntegrationDetailPage(props: { toolName={selectedTool.name} staticTool={selection?.static} policy={selectedTool.policy} - onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} - onClearPolicy={(pattern, policyId) => - void policyActions.clear(pattern, policyId) + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} + // The header badge must write and look up the SAME + // account-pinned pattern the tree row under this + // account uses, or it cannot recognize its own rule. + patternForDisplay={ + selection && !selection.static + ? accountPolicyPattern(selection.owner, selection.connection) + : undefined } {...(!selection?.static && selectedBareName ? { diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index a08faeaa67..b12c8fc558 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -8,6 +8,7 @@ import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/ import { integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { McpInstallCard } from "../components/mcp-install-card"; +import { WorkspaceAdminHint } from "../components/workspace-admin-hint"; import { Button } from "../components/button"; import { PageContainer, PageHeader } from "../components/page"; import { @@ -32,6 +33,7 @@ import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; const KIND_TO_PLUGIN_KEY: Record = { openapi: "openapi", @@ -48,6 +50,7 @@ export function IntegrationsPage() { useExecutorDocumentTitle("Integrations"); const integrations = useAtomValue(integrationsOptimisticAtom); const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); + const canCreate = useCanCreateWorkspaceConnections(); return ( @@ -55,15 +58,24 @@ export function IntegrationsPage() { title="Integrations" description="Tool providers available in this workspace." actions={ - + canCreate ? ( + + ) : ( + + + + ) } /> @@ -83,7 +95,7 @@ export function IntegrationsPage() { ), onSuccess: ({ value }) => { if (value.length === 0) { - return ; + return ; } return ( @@ -102,7 +114,7 @@ export function IntegrationsPage() { // Empty state // --------------------------------------------------------------------------- -function EmptyIntegrations() { +function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) { return (
@@ -110,17 +122,28 @@ function EmptyIntegrations() {

No integrations yet

- Connect an integration to start curating tools. + {canCreate + ? "Connect an integration to start curating tools." + : "Ask a workspace admin to add an integration."}

- + {canCreate ? ( + + ) : ( + + + + )}
); } diff --git a/packages/react/src/pages/tools.tsx b/packages/react/src/pages/tools.tsx index 60e6e0fa9a..cdb05d3e38 100644 --- a/packages/react/src/pages/tools.tsx +++ b/packages/react/src/pages/tools.tsx @@ -2,7 +2,11 @@ import { useMemo, useState } from "react"; import { Link } from "@tanstack/react-router"; import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { ToolAddress, effectivePolicyFromSorted } from "@executor-js/sdk/shared"; +import { + ToolAddress, + effectivePolicyFromSorted, + type ToolPolicyAction, +} from "@executor-js/sdk/shared"; import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms"; import { usePolicyActions } from "../hooks/use-policy-actions"; @@ -13,6 +17,7 @@ import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; // Dynamic tool policy patterns are derived from the connection-aware address. // Static tools (for example Executor's own tools) use their address directly. @@ -38,6 +43,15 @@ export function ToolsPage() { const refreshTools = useAtomRefresh(toolsAllAtom); const policies = useAtomValue(policiesOptimisticAtom); const policyActions = usePolicyActions("org"); + // Policies here are workspace rules, which the server refuses for non-admin + // members. Offer the menus only to those who can actually write them. + const canSetPolicy = useCanCreateWorkspaceConnections(); + const onSetPolicy = canSetPolicy + ? (pattern: string, action: ToolPolicyAction) => void policyActions.set(pattern, action) + : undefined; + const onClearPolicy = canSetPolicy + ? (pattern: string, policyId?: string) => void policyActions.clear(pattern, policyId) + : undefined; const [selectedToolId, setSelectedToolId] = useState(null); @@ -144,8 +158,8 @@ export function ToolsPage() { tools={summaries} selectedToolId={selectedToolId} onSelect={setSelectedToolId} - onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} - onClearPolicy={(pattern) => void policyActions.clear(pattern)} + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} policies={sortedPolicies} /> @@ -156,10 +170,8 @@ export function ToolsPage() { toolName={selectedTool.name} staticTool={selection?.static} policy={selectedTool.policy} - onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} - onClearPolicy={(pattern, policyId) => - void policyActions.clear(pattern, policyId) - } + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} /> ) : ( 0} />