# Conflicts: # apps/cloud/src/api/slack.ts # apps/cloud/src/services/slack.ts # apps/local/src/web/shell.tsx # packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx # packages/plugins/graphql/src/react/AddGraphqlSource.tsx # packages/plugins/mcp/src/react/AddMcpSource.tsx # packages/plugins/openapi/src/react/AddOpenApiSource.tsx # packages/react/src/api/optimistic.tsx # packages/react/src/pages/source-detail.tsx
Unified · Split
| @@ -11,29 +11,6 @@ import { · 10 unmodified lines | |||
| 11 | 11 | ||
| 12 | const isValidEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s); | 12 | const isValidEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s); |
| 13 | 13 | ||
| 14 | const verifyTurnstile = (token: string, remoteIp: string | null) => | ||
| 15 | Effect.tryPromise({ | ||
| 16 | try: async () => { | ||
| 17 | const secret = env.TURNSTILE_SECRET_KEY; | ||
| 18 | if (!secret) { | ||
| 19 | // Turnstile is unconfigured — fail closed in prod, but allow dev to | ||
| 20 | // boot without it. We treat empty secret as "skip" so the form works | ||
| 21 | // before secrets are wired; remove this branch once you've set the | ||
| 22 | // secret in every environment. | ||
| 23 | return { success: true, skipped: true }; | ||
| 24 | } | ||
| 25 | const form = new URLSearchParams({ secret, response: token }); | ||
| 26 | if (remoteIp) form.set("remoteip", remoteIp); | ||
| 27 | const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { | ||
| 28 | method: "POST", | ||
| 29 | body: form, | ||
| 30 | }); | ||
| 31 | const json = (await res.json()) as { success: boolean; "error-codes"?: string[] }; | ||
| 32 | return { success: json.success, errorCodes: json["error-codes"] ?? [] }; | ||
| 33 | }, | ||
| 34 | catch: (cause) => ({ success: false as const, fetchError: cause }), | ||
| 35 | }); | ||
| 36 | |||
| 37 | const handler = Effect.gen(function* () { | 14 | const handler = Effect.gen(function* () { |
| 38 | const request = yield* HttpServerRequest.HttpServerRequest; | 15 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 39 | 16 | ||
| @@ -55,20 +32,14 @@ const handler = Effect.gen(function* () { · 15 unmodified lines | |||
| 55 | }), | 32 | }), |
| 56 | )) as { | 33 | )) as { |
| 57 | email?: unknown; | 34 | email?: unknown; |
| 58 | name?: unknown; | ||
| 59 | note?: unknown; | ||
| 60 | organization?: unknown; | 35 | organization?: unknown; |
| 61 | turnstileToken?: unknown; | ||
| 62 | }; | 36 | }; |
| 63 | 37 | ||
| 64 | const trimmed = (v: unknown, max: number): string | undefined => | 38 | const trimmed = (v: unknown, max: number): string | undefined => |
| 65 | typeof v === "string" && v.trim().length > 0 ? v.trim().slice(0, max) : undefined; | 39 | typeof v === "string" && v.trim().length > 0 ? v.trim().slice(0, max) : undefined; |
| 66 | 40 | ||
| 67 | const email = typeof body.email === "string" ? body.email.trim() : ""; | 41 | const email = typeof body.email === "string" ? body.email.trim() : ""; |
| 68 | const name = trimmed(body.name, 200); | ||
| 69 | const note = trimmed(body.note, 2000); | ||
| 70 | const organization = trimmed(body.organization, 200); | 42 | const organization = trimmed(body.organization, 200); |
| 71 | const turnstileToken = typeof body.turnstileToken === "string" ? body.turnstileToken : ""; | ||
| 72 | 43 | ||
| 73 | if (!isValidEmail(email)) { | 44 | if (!isValidEmail(email)) { |
| 74 | return yield* new HttpResponseError({ | 45 | return yield* new HttpResponseError({ |
| @@ -78,28 +49,8 @@ const handler = Effect.gen(function* () { · 3 unmodified lines | |||
| 78 | }); | 49 | }); |
| 79 | } | 50 | } |
| 80 | 51 | ||
| 81 | if (!turnstileToken) { | 52 | // Global rate limit — single shared bucket keyed at "global". Per-IP gating |
| 82 | return yield* new HttpResponseError({ | 53 | // belongs at the edge (Cloudflare Rules). |
| 83 | status: 400, | ||
| 84 | code: "captcha_required", | ||
| 85 | message: "Captcha verification is required.", | ||
| 86 | }); | ||
| 87 | } | ||
| 88 | |||
| 89 | const remoteIp = request.headers["cf-connecting-ip"] ?? null; | ||
| 90 | const verification = yield* verifyTurnstile(turnstileToken, remoteIp); | ||
| 91 | if (!verification.success) { | ||
| 92 | console.error("[slack] turnstile verification failed:", verification); | ||
| 93 | return yield* new HttpResponseError({ | ||
| 94 | status: 403, | ||
| 95 | code: "captcha_failed", | ||
| 96 | message: "Captcha verification failed. Please try again.", | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | |||
| 100 | // Global daily channel-creation cap — bounds the worst case if Turnstile is | ||
| 101 | // bypassed somehow. Per-IP gating belongs at the edge (Cloudflare Rules); | ||
| 102 | // this binding is a single shared bucket keyed at "global". | ||
| 103 | const limit = yield* Effect.tryPromise({ | 54 | const limit = yield* Effect.tryPromise({ |
| 104 | try: () => env.SLACK_INVITE_LIMITER.limit({ key: "global" }), | 55 | try: () => env.SLACK_INVITE_LIMITER.limit({ key: "global" }), |
| 105 | catch: (cause) => ({ success: false as const, fetchError: cause }), | 56 | catch: (cause) => ({ success: false as const, fetchError: cause }), |
| @@ -114,7 +65,7 @@ const handler = Effect.gen(function* () { · 8 unmodified lines | |||
| 114 | } | 65 | } |
| 115 | 66 | ||
| 116 | const slack = yield* SlackService; | 67 | const slack = yield* SlackService; |
| 117 | const { invite } = yield* slack.createConnectInvite({ email, name, note, organization }).pipe( | 68 | const { invite } = yield* slack.createConnectInvite({ email, organization }).pipe( |
| 118 | Effect.tapError((err) => | 69 | Effect.tapError((err) => |
| 119 | Effect.sync(() => { | 70 | Effect.sync(() => { |
| 120 | console.error(`[slack] ${err.method} failed:`, err.error); | 71 | console.error(`[slack] ${err.method} failed:`, err.error); |
| @@ -25,11 +25,6 @@ declare global { · 24 unmodified lines | |||
| 25 | 25 | ||
| 26 | // Contact / Slack Connect | 26 | // Contact / Slack Connect |
| 27 | SLACK_BOT_TOKEN?: string; | 27 | SLACK_BOT_TOKEN?: string; |
| 28 | // Turnstile (Cloudflare CAPTCHA) — used to gate the public Slack-contact | ||
| 29 | // endpoint. Sitekey is public and ships in `vars`; secret is set via | ||
| 30 | // `wrangler secret put TURNSTILE_SECRET_KEY`. | ||
| 31 | TURNSTILE_SECRET_KEY?: string; | ||
| 32 | VITE_PUBLIC_TURNSTILE_SITEKEY?: string; | ||
| 33 | // Cloudflare ratelimit binding declared in wrangler.jsonc — caps total | 28 | // Cloudflare ratelimit binding declared in wrangler.jsonc — caps total |
| 34 | // Slack-contact channel creations across all callers. | 29 | // Slack-contact channel creations across all callers. |
| 35 | SLACK_INVITE_LIMITER: { limit: (input: { key: string }) => Promise<{ success: boolean }> }; | 30 | SLACK_INVITE_LIMITER: { limit: (input: { key: string }) => Promise<{ success: boolean }> }; |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { useEffect, useRef, useState } from "react"; | 1 | import { useEffect, useState } from "react"; |
| 2 | import { createFileRoute, Link } from "@tanstack/react-router"; | 2 | import { createFileRoute, Link } from "@tanstack/react-router"; |
| 3 | import { useAuth } from "../web/auth"; | 3 | import { useAuth } from "../web/auth"; |
| 4 | import { useCustomer, useListPlans } from "autumn-js/react"; | 4 | import { useCustomer, useListPlans } from "autumn-js/react"; |
| @@ -16,7 +16,6 @@ import { · 11 unmodified lines | |||
| 16 | } from "@executor-js/react/components/dialog"; | 16 | } from "@executor-js/react/components/dialog"; |
| 17 | import { Input } from "@executor-js/react/components/input"; | 17 | import { Input } from "@executor-js/react/components/input"; |
| 18 | import { Label } from "@executor-js/react/components/label"; | 18 | import { Label } from "@executor-js/react/components/label"; |
| 19 | import { Textarea } from "@executor-js/react/components/textarea"; | ||
| 20 | 19 | ||
| 21 | type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number]; | 20 | type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number]; |
| 22 | 21 | ||
| @@ -310,40 +309,27 @@ function SlackContactCta() { · 287 unmodified lines | |||
| 310 | const auth = useAuth(); | 309 | const auth = useAuth(); |
| 311 | const signedIn = auth.status === "authenticated" ? auth : null; | 310 | const signedIn = auth.status === "authenticated" ? auth : null; |
| 312 | const prefillEmail = signedIn?.user.email ?? ""; | 311 | const prefillEmail = signedIn?.user.email ?? ""; |
| 313 | const prefillName = signedIn?.user.name ?? ""; | ||
| 314 | const orgName = signedIn?.organization?.name ?? ""; | 312 | const orgName = signedIn?.organization?.name ?? ""; |
| 315 | 313 | ||
| 316 | const turnstileSiteKey = import.meta.env.VITE_PUBLIC_TURNSTILE_SITEKEY ?? ""; | ||
| 317 | const [open, setOpen] = useState(false); | 314 | const [open, setOpen] = useState(false); |
| 318 | const [email, setEmail] = useState(prefillEmail); | 315 | const [email, setEmail] = useState(prefillEmail); |
| 319 | const [name, setName] = useState(prefillName); | ||
| 320 | const [note, setNote] = useState(""); | ||
| 321 | const [submitting, setSubmitting] = useState(false); | 316 | const [submitting, setSubmitting] = useState(false); |
| 322 | const [error, setError] = useState<string | null>(null); | 317 | const [error, setError] = useState<string | null>(null); |
| 323 | const [inviteUrl, setInviteUrl] = useState<string | null>(null); | 318 | const [inviteUrl, setInviteUrl] = useState<string | null>(null); |
| 324 | const [turnstileToken, setTurnstileToken] = useState<string | null>(null); | ||
| 325 | 319 | ||
| 326 | // Hydrate prefill once auth resolves (it starts as `loading` on first render). | 320 | // Hydrate prefill once auth resolves (it starts as `loading` on first render). |
| 327 | useEffect(() => { | 321 | useEffect(() => { |
| 328 | if (prefillEmail && !email) setEmail(prefillEmail); | 322 | if (prefillEmail && !email) setEmail(prefillEmail); |
| 329 | if (prefillName && !name) setName(prefillName); | 323 | }, [prefillEmail]); // eslint-disable-line react-hooks/exhaustive-deps |
| 330 | }, [prefillEmail, prefillName]); // eslint-disable-line react-hooks/exhaustive-deps | ||
| 331 | 324 | ||
| 332 | const reset = () => { | 325 | const reset = () => { |
| 333 | setEmail(prefillEmail); | 326 | setEmail(prefillEmail); |
| 334 | setName(prefillName); | ||
| 335 | setNote(""); | ||
| 336 | setError(null); | 327 | setError(null); |
| 337 | setInviteUrl(null); | 328 | setInviteUrl(null); |
| 338 | setTurnstileToken(null); | ||
| 339 | }; | 329 | }; |
| 340 | 330 | ||
| 341 | const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => { | 331 | const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => { |
| 342 | e.preventDefault(); | 332 | e.preventDefault(); |
| 343 | if (!turnstileToken) { | ||
| 344 | setError("Please complete the captcha first."); | ||
| 345 | return; | ||
| 346 | } | ||
| 347 | setSubmitting(true); | 333 | setSubmitting(true); |
| 348 | setError(null); | 334 | setError(null); |
| 349 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser fetch submit path maps network failures to public UI copy | 335 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser fetch submit path maps network failures to public UI copy |
| @@ -353,10 +339,7 @@ function SlackContactCta() { · 3 unmodified lines | |||
| 353 | headers: { "content-type": "application/json" }, | 339 | headers: { "content-type": "application/json" }, |
| 354 | body: JSON.stringify({ | 340 | body: JSON.stringify({ |
| 355 | email, | 341 | email, |
| 356 | name: name || undefined, | ||
| 357 | note: note || undefined, | ||
| 358 | organization: orgName || undefined, | 342 | organization: orgName || undefined, |
| 359 | turnstileToken, | ||
| 360 | }), | 343 | }), |
| 361 | }); | 344 | }); |
| 362 | const data = (await res.json().then( | 345 | const data = (await res.json().then( |
| @@ -372,9 +355,6 @@ function SlackContactCta() { · 9 unmodified lines | |||
| 372 | setError("Network error. Please try again."); | 355 | setError("Network error. Please try again."); |
| 373 | } finally { | 356 | } finally { |
| 374 | setSubmitting(false); | 357 | setSubmitting(false); |
| 375 | // Turnstile tokens are single-use server-side — clear so the widget | ||
| 376 | // re-issues a fresh one on retry. | ||
| 377 | setTurnstileToken(null); | ||
| 378 | } | 358 | } |
| 379 | }; | 359 | }; |
| 380 | 360 | ||
| @@ -449,29 +429,6 @@ function SlackContactCta() { · 68 unmodified lines | |||
| 449 | placeholder="you@company.com" | 429 | placeholder="you@company.com" |
| 450 | /> | 430 | /> |
| 451 | </div> | 431 | </div> |
| 452 | <div className="grid gap-2"> | ||
| 453 | <Label htmlFor="slack-contact-name">Name (optional)</Label> | ||
| 454 | <Input | ||
| 455 | id="slack-contact-name" | ||
| 456 | autoComplete="name" | ||
| 457 | value={name} | ||
| 458 | onChange={(e) => setName(e.currentTarget.value)} | ||
| 459 | placeholder="Ada Lovelace" | ||
| 460 | /> | ||
| 461 | </div> | ||
| 462 | <div className="grid gap-2"> | ||
| 463 | <Label htmlFor="slack-contact-note">What's on your mind? (optional)</Label> | ||
| 464 | <Textarea | ||
| 465 | id="slack-contact-note" | ||
| 466 | rows={3} | ||
| 467 | value={note} | ||
| 468 | onChange={(e) => setNote(e.currentTarget.value)} | ||
| 469 | placeholder="Pricing question, integration help, anything…" | ||
| 470 | /> | ||
| 471 | </div> | ||
| 472 | {turnstileSiteKey && ( | ||
| 473 | <TurnstileWidget siteKey={turnstileSiteKey} onToken={setTurnstileToken} /> | ||
| 474 | )} | ||
| 475 | {error && <p className="text-sm text-destructive">{error}</p>} | 432 | {error && <p className="text-sm text-destructive">{error}</p>} |
| 476 | </div> | 433 | </div> |
| 477 | <DialogFooter> | 434 | <DialogFooter> |
| @@ -480,10 +437,7 @@ function SlackContactCta() { · 2 unmodified lines | |||
| 480 | Cancel | 437 | Cancel |
| 481 | </Button> | 438 | </Button> |
| 482 | </DialogClose> | 439 | </DialogClose> |
| 483 | <Button | 440 | <Button type="submit" disabled={submitting || !email}> |
| 484 | type="submit" | ||
| 485 | disabled={submitting || !email || (!!turnstileSiteKey && !turnstileToken)} | ||
| 486 | > | ||
| 487 | {submitting ? "Sending…" : "Send invite"} | 441 | {submitting ? "Sending…" : "Send invite"} |
| 488 | </Button> | 442 | </Button> |
| 489 | </DialogFooter> | 443 | </DialogFooter> |
| @@ -515,86 +469,6 @@ function MailIcon({ className }: { className?: string }) { · 25 unmodified lines | |||
| 515 | ); | 469 | ); |
| 516 | } | 470 | } |
| 517 | 471 | ||
| 518 | type TurnstileApi = { | ||
| 519 | render: ( | ||
| 520 | el: HTMLElement, | ||
| 521 | opts: { | ||
| 522 | sitekey: string; | ||
| 523 | callback?: (token: string) => void; | ||
| 524 | "error-callback"?: () => void; | ||
| 525 | "expired-callback"?: () => void; | ||
| 526 | theme?: "light" | "dark" | "auto"; | ||
| 527 | }, | ||
| 528 | ) => string; | ||
| 529 | remove: (id: string) => void; | ||
| 530 | }; | ||
| 531 | |||
| 532 | declare global { | ||
| 533 | interface Window { | ||
| 534 | turnstile?: TurnstileApi; | ||
| 535 | } | ||
| 536 | } | ||
| 537 | |||
| 538 | function TurnstileWidget({ | ||
| 539 | siteKey, | ||
| 540 | onToken, | ||
| 541 | }: { | ||
| 542 | siteKey: string; | ||
| 543 | onToken: (token: string | null) => void; | ||
| 544 | }) { | ||
| 545 | const containerRef = useRef<HTMLDivElement>(null); | ||
| 546 | const widgetIdRef = useRef<string | null>(null); | ||
| 547 | const onTokenRef = useRef(onToken); | ||
| 548 | onTokenRef.current = onToken; | ||
| 549 | |||
| 550 | useEffect(() => { | ||
| 551 | let cancelled = false; | ||
| 552 | const render = () => { | ||
| 553 | if (cancelled || !containerRef.current || !window.turnstile) return; | ||
| 554 | if (widgetIdRef.current) { | ||
| 555 | window.turnstile.remove(widgetIdRef.current); | ||
| 556 | widgetIdRef.current = null; | ||
| 557 | } | ||
| 558 | widgetIdRef.current = window.turnstile.render(containerRef.current, { | ||
| 559 | sitekey: siteKey, | ||
| 560 | callback: (token) => onTokenRef.current(token), | ||
| 561 | "error-callback": () => onTokenRef.current(null), | ||
| 562 | "expired-callback": () => onTokenRef.current(null), | ||
| 563 | }); | ||
| 564 | }; | ||
| 565 | |||
| 566 | if (window.turnstile) { | ||
| 567 | render(); | ||
| 568 | } else { | ||
| 569 | const existing = document.querySelector<HTMLScriptElement>("script[data-turnstile]"); | ||
| 570 | if (existing) { | ||
| 571 | existing.addEventListener("load", render); | ||
| 572 | return () => { | ||
| 573 | cancelled = true; | ||
| 574 | existing.removeEventListener("load", render); | ||
| 575 | }; | ||
| 576 | } | ||
| 577 | const script = document.createElement("script"); | ||
| 578 | script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; | ||
| 579 | script.async = true; | ||
| 580 | script.defer = true; | ||
| 581 | script.dataset.turnstile = "1"; | ||
| 582 | script.addEventListener("load", render); | ||
| 583 | document.head.appendChild(script); | ||
| 584 | } | ||
| 585 | |||
| 586 | return () => { | ||
| 587 | cancelled = true; | ||
| 588 | if (widgetIdRef.current && window.turnstile) { | ||
| 589 | window.turnstile.remove(widgetIdRef.current); | ||
| 590 | widgetIdRef.current = null; | ||
| 591 | } | ||
| 592 | }; | ||
| 593 | }, [siteKey]); | ||
| 594 | |||
| 595 | return <div ref={containerRef} className="flex justify-center" />; | ||
| 596 | } | ||
| 597 | |||
| 598 | function SlackMark({ className }: { className?: string }) { | 472 | function SlackMark({ className }: { className?: string }) { |
| 599 | return ( | 473 | return ( |
| 600 | <svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden> | 474 | <svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden> |
| @@ -5,18 +5,17 @@ · 4 unmodified lines | |||
| 5 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| 6 | 6 | ||
| 7 | import { env } from "cloudflare:workers"; | 7 | import { env } from "cloudflare:workers"; |
| 8 | import { Context, Data, Effect, Layer, Schema } from "effect"; | 8 | import { Context, Data, Effect, Layer } from "effect"; |
| 9 | 9 | ||
| 10 | export class SlackError extends Data.TaggedError("SlackError")<{ | 10 | export class SlackError extends Data.TaggedError("SlackError")<{ |
| 11 | method: string; | 11 | method: string; |
| 12 | error: string; | 12 | error: string; |
| 13 | cause?: unknown; | ||
| 13 | }> {} | 14 | }> {} |
| 14 | 15 | ||
| 15 | export type ISlackService = Readonly<{ | 16 | export type ISlackService = Readonly<{ |
| 16 | createConnectInvite: (input: { | 17 | createConnectInvite: (input: { |
| 17 | email: string; | 18 | email: string; |
| 18 | name?: string; | ||
| 19 | note?: string; | ||
| 20 | organization?: string; | 19 | organization?: string; |
| 21 | }) => Effect.Effect< | 20 | }) => Effect.Effect< |
| 22 | { | 21 | { |
| @@ -27,8 +26,8 @@ export type ISlackService = Readonly<{ · 4 unmodified lines | |||
| 27 | >; | 26 | >; |
| 28 | }>; | 27 | }>; |
| 29 | 28 | ||
| 30 | const slugifyEmail = (email: string): string => | 29 | const slugify = (s: string): string => |
| 31 | 30 | s | |
| 32 | .toLowerCase() | 31 | .toLowerCase() |
| 33 | .replace(/[^a-z0-9]+/g, "-") | 32 | .replace(/[^a-z0-9]+/g, "-") |
| 34 | .replace(/^-+|-+$/g, "") | 33 | .replace(/^-+|-+$/g, "") |
| @@ -40,95 +39,64 @@ const randomSuffix = (): string => { · 5 unmodified lines | |||
| 40 | return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); | 39 | return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); |
| 41 | }; | 40 | }; |
| 42 | 41 | ||
| 43 | const SlackErrorResponse = Schema.Struct({ | 42 | type SlackResponse = { ok: boolean; error?: string } & Record<string, unknown>; |
| 44 | ok: Schema.Literal(false), | ||
| 45 | error: Schema.optional(Schema.String), | ||
| 46 | }); | ||
| 47 | |||
| 48 | const SlackChannel = Schema.Struct({ id: Schema.String, name: Schema.String }); | ||
| 49 | |||
| 50 | const SlackPostMessageResponse = Schema.Struct({ ok: Schema.Literal(true) }); | ||
| 51 | |||
| 52 | const SlackCreateChannelResponse = Schema.Struct({ | ||
| 53 | ok: Schema.Literal(true), | ||
| 54 | channel: SlackChannel, | ||
| 55 | }); | ||
| 56 | |||
| 57 | const SlackSharedInviteResponse = Schema.Struct({ | ||
| 58 | ok: Schema.Literal(true), | ||
| 59 | invite_id: Schema.String, | ||
| 60 | url: Schema.String, | ||
| 61 | }); | ||
| 62 | 43 | ||
| 63 | const make = Effect.sync(() => { | 44 | const make = Effect.sync(() => { |
| 64 | const token = env.SLACK_BOT_TOKEN; | 45 | const token = env.SLACK_BOT_TOKEN; |
| 65 | 46 | ||
| 66 | if (!token) { | 47 | if (!token) { |
| 67 | const notConfigured = (method: string) => | 48 | const notConfigured = (method: string) => |
| 68 | Effect.fail(new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" })); | 49 | Effect.fail( |
| 50 | new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" }), | ||
| 51 | ); | ||
| 69 | return { | 52 | return { |
| 70 | createConnectInvite: () => notConfigured("createConnectInvite"), | 53 | createConnectInvite: () => notConfigured("createConnectInvite"), |
| 71 | } satisfies ISlackService; | 54 | } satisfies ISlackService; |
| 72 | } | 55 | } |
| 73 | 56 | ||
| 74 | const call = <A extends { ok: true }>( | 57 | const call = <A extends SlackResponse>(method: string, body: Record<string, unknown>) => |
| 75 | method: string, | 58 | Effect.tryPromise({ |
| 76 | body: Record<string, unknown>, | 59 | try: async (): Promise<A> => { |
| 77 | successSchema: Schema.Decoder<A>, | 60 | const res = await fetch(`https://slack.com/api/${method}`, { |
| 78 | ) => | 61 | method: "POST", |
| 79 | Effect.gen(function* () { | 62 | headers: { |
| 80 | const json = yield* Effect.tryPromise({ | 63 | "content-type": "application/json; charset=utf-8", |
| 81 | try: async (): Promise<unknown> => { | 64 | authorization: `Bearer ${token}`, |
| 82 | const res = await fetch(`https://slack.com/api/${method}`, { | 65 | }, |
| 83 | method: "POST", | 66 | body: JSON.stringify(body), |
| 84 | headers: { | ||
| 85 | "content-type": "application/json; charset=utf-8", | ||
| 86 | authorization: `Bearer ${token}`, | ||
| 87 | }, | ||
| 88 | body: JSON.stringify(body), | ||
| 89 | }); | ||
| 90 | return res.json(); | ||
| 91 | }, | ||
| 92 | catch: () => | ||
| 93 | new SlackError({ | ||
| 94 | method, | ||
| 95 | error: "Failed to read Slack API response", | ||
| 96 | }), | ||
| 97 | }); | ||
| 98 | |||
| 99 | const response = yield* Schema.decodeUnknownEffect( | ||
| 100 | Schema.Union([successSchema, SlackErrorResponse]), | ||
| 101 | )(json).pipe( | ||
| 102 | Effect.mapError( | ||
| 103 | () => | ||
| 104 | new SlackError({ | ||
| 105 | method, | ||
| 106 | error: "Unexpected Slack API response", | ||
| 107 | }), | ||
| 108 | ), | ||
| 109 | ); | ||
| 110 | |||
| 111 | if (!response.ok) { | ||
| 112 | return yield* new SlackError({ | ||
| 113 | method, | ||
| 114 | error: response.error ?? "unknown_slack_error", | ||
| 115 | }); | 67 | }); |
| 116 | } | 68 | const json = (await res.json()) as A; |
| 117 | 69 | return json; | |
| 118 | return response; | 70 | }, |
| 119 | }).pipe(Effect.withSpan(`slack.${method}`)); | 71 | catch: (cause) => |
| 72 | new SlackError({ | ||
| 73 | method, | ||
| 74 | error: "Slack request failed", | ||
| 75 | cause, | ||
| 76 | }), | ||
| 77 | }).pipe( | ||
| 78 | Effect.flatMap((json) => | ||
| 79 | json.ok | ||
| 80 | ? Effect.succeed(json) | ||
| 81 | : Effect.fail(new SlackError({ method, error: json.error ?? "unknown_slack_error" })), | ||
| 82 | ), | ||
| 83 | Effect.withSpan(`slack.${method}`), | ||
| 84 | ); | ||
| 120 | 85 | ||
| 121 | const createConnectInvite: ISlackService["createConnectInvite"] = ({ | 86 | const createConnectInvite: ISlackService["createConnectInvite"] = ({ |
| 122 | email, | 87 | email, |
| 123 | name, | ||
| 124 | note, | ||
| 125 | organization, | 88 | organization, |
| 126 | }) => | 89 | }) => |
| 127 | Effect.gen(function* () { | 90 | Effect.gen(function* () { |
| 128 | // Slack channel names: lowercase, no spaces, max 80 chars, unique per workspace. | 91 | // Slack channel names: lowercase, no spaces, max 80 chars, unique per |
| 129 | const baseName = `shared-${slugifyEmail(email)}`.slice(0, 80); | 92 | // workspace. Use the org name when available; fall back to the email |
| 93 | // local-part for unauthenticated submissions. | ||
| 94 | const baseName = slugify(organization ?? email).slice(0, 80) || "contact"; | ||
| 130 | const tryCreate = (n: string) => | 95 | const tryCreate = (n: string) => |
| 131 | call("conversations.create", { name: n, is_private: false }, SlackCreateChannelResponse); | 96 | call<SlackResponse & { channel: { id: string; name: string } }>( |
| 97 | "conversations.create", | ||
| 98 | { name: n, is_private: false }, | ||
| 99 | ); | ||
| 132 | 100 | ||
| 133 | const created = yield* tryCreate(baseName).pipe( | 101 | const created = yield* tryCreate(baseName).pipe( |
| 134 | Effect.catchTag("SlackError", (err) => | 102 | Effect.catchTag("SlackError", (err) => |
| @@ -139,39 +107,27 @@ const make = Effect.sync(() => { · 4 unmodified lines | |||
| 139 | ); | 107 | ); |
| 140 | const channel = created.channel; | 108 | const channel = created.channel; |
| 141 | 109 | ||
| 142 | const helloLines = [ | 110 | const invite = yield* call<SlackResponse & { invite_id: string; url: string }>( |
| 143 | `:wave: New contact from pricing page: ${email}`, | ||
| 144 | name ? `Name: ${name}` : null, | ||
| 145 | organization ? `Org: ${organization}` : null, | ||
| 146 | note ? `Note: ${note}` : null, | ||
| 147 | ].filter(Boolean) as string[]; | ||
| 148 | |||
| 149 | yield* call( | ||
| 150 | "chat.postMessage", | ||
| 151 | { | ||
| 152 | channel: channel.id, | ||
| 153 | text: helloLines.join("\n"), | ||
| 154 | }, | ||
| 155 | SlackPostMessageResponse, | ||
| 156 | ); | ||
| 157 | |||
| 158 | const invite = yield* call( | ||
| 159 | "conversations.inviteShared", | 111 | "conversations.inviteShared", |
| 160 | { | 112 | { |
| 161 | channel: channel.id, | 113 | channel: channel.id, |
| 162 | emails: [email], | 114 | emails: [email], |
| 163 | // `external_limited: true` scopes the guest to just this channel, | ||
| 164 | // which is what we want for a focused 1:1 support conversation. | ||
| 165 | external_limited: true, | ||
| 166 | }, | 115 | }, |
| 167 | SlackSharedInviteResponse, | 116 | ); |
| 117 | |||
| 118 | // Best-effort: leave the channel so the bot doesn't sit in every | ||
| 119 | // contact channel forever. Don't fail the request if leave errors. | ||
| 120 | yield* call<SlackResponse>("conversations.leave", { channel: channel.id }).pipe( | ||
| 121 | Effect.ignore, | ||
| 168 | ); | 122 | ); |
| 169 | 123 | ||
| 170 | return { | 124 | return { |
| 171 | channel: { id: channel.id, name: channel.name }, | 125 | channel: { id: channel.id, name: channel.name }, |
| 172 | invite: { invite_id: invite.invite_id, url: invite.url }, | 126 | invite: { invite_id: invite.invite_id, url: invite.url }, |
| 173 | }; | 127 | }; |
| 174 | }).pipe(Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } })); | 128 | }).pipe( |
| 129 | Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }), | ||
| 130 | ); | ||
| 175 | 131 | ||
| 176 | return { createConnectInvite } satisfies ISlackService; | 132 | return { createConnectInvite } satisfies ISlackService; |
| 177 | }); | 133 | }); |
| @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; · 2 unmodified lines | |||
| 3 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | 3 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | import * as Exit from "effect/Exit"; | 5 | import * as Exit from "effect/Exit"; |
| 6 | import { useSourcesWithPending } from "@executor-js/react/api/optimistic"; | 6 | import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; |
| 7 | import { useScope } from "@executor-js/react/api/scope-context"; | 7 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 8 | import { Button } from "@executor-js/react/components/button"; | 8 | import { Button } from "@executor-js/react/components/button"; |
| 9 | import { Skeleton } from "@executor-js/react/components/skeleton"; | 9 | import { Skeleton } from "@executor-js/react/components/skeleton"; |
| @@ -60,7 +60,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate · 50 unmodified lines | |||
| 60 | 60 | ||
| 61 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { | 61 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { |
| 62 | const scopeId = useScope(); | 62 | const scopeId = useScope(); |
| 63 | const sources = useSourcesWithPending(scopeId); | 63 | const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); |
| 64 | 64 | ||
| 65 | return AsyncResult.match(sources, { | 65 | return AsyncResult.match(sources, { |
| 66 | onInitial: () => ( | 66 | onInitial: () => ( |
| @@ -54,15 +54,12 @@ · 53 unmodified lines | |||
| 54 | "vars": { | 54 | "vars": { |
| 55 | "VITE_PUBLIC_SITE_URL": "https://executor.sh", | 55 | "VITE_PUBLIC_SITE_URL": "https://executor.sh", |
| 56 | "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", | 56 | "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", |
| 57 | // Replace with your Turnstile production sitekey before going live. The | ||
| 58 | // test key always passes — fine for local dev, useless in prod. | ||
| 59 | "VITE_PUBLIC_TURNSTILE_SITEKEY": "1x00000000000000000000AA", | ||
| 60 | }, | 57 | }, |
| 61 | // Cloudflare's `simple` ratelimit only supports 10s or 60s windows — it's | 58 | // Cloudflare's `simple` ratelimit only supports 10s or 60s windows — it's |
| 62 | // burst protection, not a true daily cap. With Turnstile gating the form, | 59 | // burst protection, not a true daily cap. 5/min keeps the worst-case |
| 63 | // 5/min keeps the worst-case sustained throughput to ~7k channels/day, | 60 | // sustained throughput to ~7k channels/day, which is bounded enough as a |
| 64 | // which is bounded enough as a backstop. For an actual daily ceiling, | 61 | // backstop. For an actual daily ceiling, layer a Durable Object counter |
| 65 | // layer a Durable Object counter on top. | 62 | // on top. |
| 66 | "ratelimits": [ | 63 | "ratelimits": [ |
| 67 | { | 64 | { |
| 68 | "name": "SLACK_INVITE_LIMITER", | 65 | "name": "SLACK_INVITE_LIMITER", |
| @@ -1,10 +1,10 @@ | |||
| 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; | 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; |
| 2 | import { useCallback, useEffect, useRef, useState } from "react"; | 2 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 3 | import { useAtomRefresh } from "@effect/atom-react"; | 3 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 4 | import { Data, Effect, Exit } from "effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 6 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 6 | import { sourcesAtom, toolsAtom } from "@executor-js/react/api/atoms"; | 7 | import { sourcesAtom, sourcesOptimisticAtom, toolsAtom } from "@executor-js/react/api/atoms"; |
| 7 | import { useSourcesWithPending } from "@executor-js/react/api/optimistic"; | ||
| 8 | import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context"; | 8 | import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context"; |
| 9 | import { Button } from "@executor-js/react/components/button"; | 9 | import { Button } from "@executor-js/react/components/button"; |
| 10 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; | 10 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; |
| @@ -30,10 +30,6 @@ type UpdateChannel = "latest" | "beta"; · 19 unmodified lines | |||
| 30 | 30 | ||
| 31 | const EXECUTOR_DIST_TAGS_PATH = "/v1/app/npm/dist-tags"; | 31 | const EXECUTOR_DIST_TAGS_PATH = "/v1/app/npm/dist-tags"; |
| 32 | 32 | ||
| 33 | class LatestVersionCheckError extends Data.TaggedError("LatestVersionCheckError")<{ | ||
| 34 | readonly cause: unknown; | ||
| 35 | }> {} | ||
| 36 | |||
| 37 | type ParsedVersion = { | 33 | type ParsedVersion = { |
| 38 | readonly major: number; | 34 | readonly major: number; |
| 39 | readonly minor: number; | 35 | readonly minor: number; |
| @@ -100,22 +96,20 @@ function useLatestVersion(currentVersion: string) { · 60 unmodified lines | |||
| 100 | 96 | ||
| 101 | useEffect(() => { | 97 | useEffect(() => { |
| 102 | let cancelled = false; | 98 | let cancelled = false; |
| 103 | 99 | void Effect.runPromiseExit( | |
| 104 | const latestVersionEffect = Effect.tryPromise({ | 100 | Effect.tryPromise({ |
| 105 | try: async () => { | 101 | try: async () => { |
| 106 | const res = await fetch(EXECUTOR_DIST_TAGS_PATH); | 102 | const res = await fetch(EXECUTOR_DIST_TAGS_PATH); |
| 107 | if (!res.ok) return {}; | 103 | if (!res.ok) return null; |
| 108 | return (await res.json()) as Partial<Record<UpdateChannel, string>>; | 104 | return (await res.json()) as Partial<Record<UpdateChannel, string>>; |
| 109 | }, | 105 | }, |
| 110 | catch: (cause) => new LatestVersionCheckError({ cause }), | 106 | catch: (cause) => cause, |
| 111 | }); | 107 | }), |
| 112 | 108 | ).then((exit) => { | |
| 113 | void Effect.runPromiseExit(latestVersionEffect).then((exit) => { | ||
| 114 | if (!cancelled && Exit.isSuccess(exit)) { | 109 | if (!cancelled && Exit.isSuccess(exit)) { |
| 115 | setLatestVersion(exit.value[channel] ?? null); | 110 | setLatestVersion(exit.value?.[channel] ?? null); |
| 116 | } | 111 | } |
| 117 | }); | 112 | }); |
| 118 | |||
| 119 | return () => { | 113 | return () => { |
| 120 | cancelled = true; | 114 | cancelled = true; |
| 121 | }; | 115 | }; |
| @@ -273,7 +267,7 @@ function PluginNav(props: { pathname: string; onNavigate?: () => void }) { · 151 unmodified lines | |||
| 273 | 267 | ||
| 274 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { | 268 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { |
| 275 | const scopeId = useScope(); | 269 | const scopeId = useScope(); |
| 276 | const sources = useSourcesWithPending(scopeId); | 270 | const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); |
| 277 | 271 | ||
| 278 | return AsyncResult.match(sources, { | 272 | return AsyncResult.match(sources, { |
| 279 | onInitial: () => <div className="px-2.5 py-2 text-xs text-muted-foreground">Loading…</div>, | 273 | onInitial: () => <div className="px-2.5 py-2 text-xs text-muted-foreground">Loading…</div>, |
| @@ -1,8 +1,9 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import { Exit, Option, Schema } from "effect"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 4 | 6 | ||
| 5 | import { usePendingSources } from "@executor-js/react/api/optimistic"; | ||
| 6 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 7 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 7 | import { useScope } from "@executor-js/react/api/scope-context"; | 8 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 8 | import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker"; | 9 | import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker"; |
| @@ -45,10 +46,21 @@ import { FloatActions } from "@executor-js/react/components/float-actions"; · 36 unmodified lines | |||
| 45 | import { Input } from "@executor-js/react/components/input"; | 46 | import { Input } from "@executor-js/react/components/input"; |
| 46 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; | 47 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; |
| 47 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; | 48 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 48 | import { addGoogleDiscoverySource, probeGoogleDiscovery } from "./atoms"; | 49 | import { addGoogleDiscoverySourceOptimistic, probeGoogleDiscovery } from "./atoms"; |
| 49 | import { GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, googleDiscoveryOAuthStrategy } from "./oauth"; | 50 | import { GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, googleDiscoveryOAuthStrategy } from "./oauth"; |
| 50 | import { googleDiscoveryPresets, type GoogleDiscoveryPreset } from "../sdk/presets"; | 51 | import { googleDiscoveryPresets, type GoogleDiscoveryPreset } from "../sdk/presets"; |
| 51 | 52 | ||
| 53 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 54 | |||
| 55 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | ||
| 56 | Option.match( | ||
| 57 | Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)), | ||
| 58 | { | ||
| 59 | onNone: () => fallback, | ||
| 60 | onSome: ({ message }) => message, | ||
| 61 | }, | ||
| 62 | ); | ||
| 63 | |||
| 52 | type GoogleAuthKind = "none" | "oauth2"; | 64 | type GoogleAuthKind = "none" | "oauth2"; |
| 53 | 65 | ||
| 54 | // --------------------------------------------------------------------------- | 66 | // --------------------------------------------------------------------------- |
| @@ -96,50 +108,25 @@ type GoogleDiscoveryTemplate = GoogleDiscoveryPreset & { · 41 unmodified lines | |||
| 96 | }; | 108 | }; |
| 97 | 109 | ||
| 98 | const GOOGLE_G_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; | 110 | const GOOGLE_G_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; |
| 99 | const PublicErrorMessage = Schema.Struct({ | ||
| 100 | _tag: Schema.Literals([ | ||
| 101 | "GoogleDiscoveryParseError", | ||
| 102 | "GoogleDiscoveryOAuthError", | ||
| 103 | "GoogleDiscoverySourceError", | ||
| 104 | ]), | ||
| 105 | message: Schema.String, | ||
| 106 | }); | ||
| 107 | |||
| 108 | const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => { | ||
| 109 | const error = Exit.findErrorOption(exit); | ||
| 110 | if (Option.isNone(error)) return fallback; | ||
| 111 | const errorMessage = Schema.decodeUnknownOption(PublicErrorMessage)(error.value); | ||
| 112 | return Option.match(errorMessage, { | ||
| 113 | onNone: () => fallback, | ||
| 114 | onSome: (value) => value.message, | ||
| 115 | }); | ||
| 116 | }; | ||
| 117 | 111 | ||
| 118 | const parseUrlOption = (value: string): URL | null => { | 112 | function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate { |
| 119 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform URL parser | 113 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes user-provided preset URLs |
| 120 | try { | 114 | try { |
| 121 | return new URL(value); | 115 | const url = new URL(preset.url); |
| 116 | const parts = url.pathname.split("/").filter(Boolean); | ||
| 117 | const apisIndex = parts.indexOf("apis"); | ||
| 118 | const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined; | ||
| 119 | const version = | ||
| 120 | apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined); | ||
| 121 | return { | ||
| 122 | ...preset, | ||
| 123 | discoveryUrl: preset.url, | ||
| 124 | service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""), | ||
| 125 | version: version ?? "", | ||
| 126 | }; | ||
| 122 | } catch { | 127 | } catch { |
| 123 | return null; | ||
| 124 | } | ||
| 125 | }; | ||
| 126 | |||
| 127 | function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate { | ||
| 128 | const url = parseUrlOption(preset.url); | ||
| 129 | if (!url) { | ||
| 130 | return { ...preset, discoveryUrl: preset.url, service: preset.id, version: "" }; | 128 | return { ...preset, discoveryUrl: preset.url, service: preset.id, version: "" }; |
| 131 | } | 129 | } |
| 132 | const parts = url.pathname.split("/").filter(Boolean); | ||
| 133 | const apisIndex = parts.indexOf("apis"); | ||
| 134 | const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined; | ||
| 135 | const version = | ||
| 136 | apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined); | ||
| 137 | return { | ||
| 138 | ...preset, | ||
| 139 | discoveryUrl: preset.url, | ||
| 140 | service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""), | ||
| 141 | version: version ?? "", | ||
| 142 | }; | ||
| 143 | } | 130 | } |
| 144 | 131 | ||
| 145 | const GOOGLE_DISCOVERY_TEMPLATES = googleDiscoveryPresets.map(parseGoogleDiscoveryPreset); | 132 | const GOOGLE_DISCOVERY_TEMPLATES = googleDiscoveryPresets.map(parseGoogleDiscoveryPreset); |
| @@ -230,8 +217,9 @@ export default function AddGoogleDiscoverySource(props: { · 84 unmodified lines | |||
| 230 | 217 | ||
| 231 | const scopeId = useScope(); | 218 | const scopeId = useScope(); |
| 232 | const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promiseExit" }); | 219 | const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promiseExit" }); |
| 233 | const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promiseExit" }); | 220 | const doAdd = useAtomSet(addGoogleDiscoverySourceOptimistic(scopeId), { |
| 234 | const { beginAdd } = usePendingSources(); | 221 | mode: "promiseExit", |
| 222 | }); | ||
| 235 | const secretList = useSecretPickerSecrets(); | 223 | const secretList = useSecretPickerSecrets(); |
| 236 | const oauth = useOAuthPopupFlow({ | 224 | const oauth = useOAuthPopupFlow({ |
| 237 | popupName: GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, | 225 | popupName: GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, |
| @@ -269,7 +257,7 @@ export default function AddGoogleDiscoverySource(props: { · 31 unmodified lines | |||
| 269 | if (Exit.isFailure(exit)) { | 257 | if (Exit.isFailure(exit)) { |
| 270 | setProbe(null); | 258 | setProbe(null); |
| 271 | setLoadingProbe(false); | 259 | setLoadingProbe(false); |
| 272 | setError(messageFromExit(exit, "Failed to inspect discovery document")); | 260 | setError(errorMessageFromExit(exit, "Failed to inspect discovery document")); |
| 273 | return; | 261 | return; |
| 274 | } | 262 | } |
| 275 | const result = exit.value; | 263 | const result = exit.value; |
| @@ -354,11 +342,6 @@ export default function AddGoogleDiscoverySource(props: { · 78 unmodified lines | |||
| 354 | setError(null); | 342 | setError(null); |
| 355 | const displayName = identity.name.trim() || probe.name; | 343 | const displayName = identity.name.trim() || probe.name; |
| 356 | const namespace = resolvedNamespace; | 344 | const namespace = resolvedNamespace; |
| 357 | const placeholder = beginAdd({ | ||
| 358 | id: namespace, | ||
| 359 | name: displayName, | ||
| 360 | kind: "google-discovery", | ||
| 361 | }); | ||
| 362 | const exit = await doAdd({ | 345 | const exit = await doAdd({ |
| 363 | params: { scopeId }, | 346 | params: { scopeId }, |
| 364 | payload: { | 347 | payload: { |
| @@ -378,9 +361,8 @@ export default function AddGoogleDiscoverySource(props: { · 13 unmodified lines | |||
| 378 | }, | 361 | }, |
| 379 | reactivityKeys: [...sourceWriteKeys], | 362 | reactivityKeys: [...sourceWriteKeys], |
| 380 | }); | 363 | }); |
| 381 | placeholder.done(); | ||
| 382 | if (Exit.isFailure(exit)) { | 364 | if (Exit.isFailure(exit)) { |
| 383 | setError(messageFromExit(exit, "Failed to add source")); | 365 | setError(errorMessageFromExit(exit, "Failed to add source")); |
| 384 | setAdding(false); | 366 | setAdding(false); |
| 385 | return; | 367 | return; |
| 386 | } | 368 | } |
| @@ -394,7 +376,6 @@ export default function AddGoogleDiscoverySource(props: { · 7 unmodified lines | |||
| 394 | oauthAuth, | 376 | oauthAuth, |
| 395 | props, | 377 | props, |
| 396 | scopeId, | 378 | scopeId, |
| 397 | beginAdd, | ||
| 398 | resolvedNamespace, | 379 | resolvedNamespace, |
| 399 | ]); | 380 | ]); |
| 400 | 381 | ||
| @@ -1,4 +1,7 @@ | |||
| 1 | import type { ScopeId } from "@executor-js/sdk/core"; | 1 | import type { ScopeId } from "@executor-js/sdk/core"; |
| 2 | import * as Atom from "effect/unstable/reactivity/Atom"; | ||
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 4 | import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; | ||
| 2 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; | 5 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; |
| 3 | import { GoogleDiscoveryClient } from "./client"; | 6 | import { GoogleDiscoveryClient } from "./client"; |
| 4 | 7 | ||
| @@ -25,6 +28,32 @@ export const addGoogleDiscoverySource = GoogleDiscoveryClient.mutation( · 20 unmodified lines | |||
| 25 | "googleDiscovery", | 28 | "googleDiscovery", |
| 26 | "addSource", | 29 | "addSource", |
| 27 | ); | 30 | ); |
| 31 | export const addGoogleDiscoverySourceOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 32 | sourcesOptimisticAtom(scopeId).pipe( | ||
| 33 | Atom.optimisticFn({ | ||
| 34 | reducer: (current, arg) => | ||
| 35 | AsyncResult.map(current, (rows) => { | ||
| 36 | const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; | ||
| 37 | const source = { | ||
| 38 | id, | ||
| 39 | scopeId, | ||
| 40 | kind: "googleDiscovery", | ||
| 41 | pluginId: "google-discovery", | ||
| 42 | name: arg.payload.name, | ||
| 43 | url: arg.payload.discoveryUrl, | ||
| 44 | canRemove: false, | ||
| 45 | canRefresh: false, | ||
| 46 | canEdit: false, | ||
| 47 | runtime: false, | ||
| 48 | }; | ||
| 49 | return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) => | ||
| 50 | a.name.localeCompare(b.name), | ||
| 51 | ); | ||
| 52 | }), | ||
| 53 | fn: addGoogleDiscoverySource, | ||
| 54 | }), | ||
| 55 | ), | ||
| 56 | ); | ||
| 28 | export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation( | 57 | export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation( |
| 29 | "googleDiscovery", | 58 | "googleDiscovery", |
| 30 | "updateSource", | 59 | "updateSource", |
| @@ -1,10 +1,11 @@ | |||
| 1 | import { useCallback, useState } from "react"; | 1 | import { useCallback, useState } from "react"; |
| 2 | import { useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 4 | 6 | ||
| 5 | import { useScope } from "@executor-js/react/api/scope-context"; | 7 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 6 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 8 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 7 | import { usePendingSources } from "@executor-js/react/api/optimistic"; | ||
| 8 | import { | 9 | import { |
| 9 | HttpCredentialsEditor, | 10 | HttpCredentialsEditor, |
| 10 | httpCredentialsValid, | 11 | httpCredentialsValid, |
| @@ -34,10 +35,21 @@ import { · 23 unmodified lines | |||
| 34 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 35 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 35 | import { Input } from "@executor-js/react/components/input"; | 36 | import { Input } from "@executor-js/react/components/input"; |
| 36 | import { Spinner } from "@executor-js/react/components/spinner"; | 37 | import { Spinner } from "@executor-js/react/components/spinner"; |
| 37 | import { addGraphqlSource } from "./atoms"; | 38 | import { addGraphqlSourceOptimistic } from "./atoms"; |
| 38 | import { initialGraphqlCredentials } from "./defaults"; | 39 | import { initialGraphqlCredentials } from "./defaults"; |
| 39 | import type { HeaderValue } from "../sdk/types"; | 40 | import type { HeaderValue } from "../sdk/types"; |
| 40 | 41 | ||
| 42 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 43 | |||
| 44 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | ||
| 45 | Option.match( | ||
| 46 | Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)), | ||
| 47 | { | ||
| 48 | onNone: () => fallback, | ||
| 49 | onSome: ({ message }) => message, | ||
| 50 | }, | ||
| 51 | ); | ||
| 52 | |||
| 41 | type AuthMode = "none" | "oauth2"; | 53 | type AuthMode = "none" | "oauth2"; |
| 42 | 54 | ||
| 43 | export default function AddGraphqlSource(props: { | 55 | export default function AddGraphqlSource(props: { |
| @@ -56,8 +68,7 @@ export default function AddGraphqlSource(props: { · 12 unmodified lines | |||
| 56 | const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null); | 68 | const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null); |
| 57 | 69 | ||
| 58 | const scopeId = useScope(); | 70 | const scopeId = useScope(); |
| 59 | const doAdd = useAtomSet(addGraphqlSource, { mode: "promiseExit" }); | 71 | const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), { mode: "promiseExit" }); |
| 60 | const { beginAdd } = usePendingSources(); | ||
| 61 | const secretList = useSecretPickerSecrets(); | 72 | const secretList = useSecretPickerSecrets(); |
| 62 | const oauth = useOAuthPopupFlow({ | 73 | const oauth = useOAuthPopupFlow({ |
| 63 | popupName: "graphql-oauth", | 74 | popupName: "graphql-oauth", |
| @@ -113,18 +124,12 @@ export default function AddGraphqlSource(props: { · 49 unmodified lines | |||
| 113 | const { headers: headerMap, queryParams } = serializeHttpCredentials(credentials); | 124 | const { headers: headerMap, queryParams } = serializeHttpCredentials(credentials); |
| 114 | 125 | ||
| 115 | const { trimmedEndpoint, namespace, displayName } = sourceIdentity(); | 126 | const { trimmedEndpoint, namespace, displayName } = sourceIdentity(); |
| 116 | const placeholder = beginAdd({ | ||
| 117 | id: namespace, | ||
| 118 | name: displayName, | ||
| 119 | kind: "graphql", | ||
| 120 | url: trimmedEndpoint || undefined, | ||
| 121 | }); | ||
| 122 | const exit = await doAdd({ | 127 | const exit = await doAdd({ |
| 123 | params: { scopeId }, | 128 | params: { scopeId }, |
| 124 | payload: { | 129 | payload: { |
| 125 | endpoint: trimmedEndpoint, | 130 | endpoint: trimmedEndpoint, |
| 126 | name: identity.name.trim() || undefined, | 131 | name: displayName, |
| 127 | namespace: slugifyNamespace(identity.namespace) || undefined, | 132 | namespace, |
| 128 | ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), | 133 | ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), |
| 129 | ...(Object.keys(queryParams).length > 0 | 134 | ...(Object.keys(queryParams).length > 0 |
| 130 | ? { queryParams: queryParams as Record<string, HeaderValue> } | 135 | ? { queryParams: queryParams as Record<string, HeaderValue> } |
| @@ -140,14 +145,11 @@ export default function AddGraphqlSource(props: { · 9 unmodified lines | |||
| 140 | }, | 145 | }, |
| 141 | reactivityKeys: sourceWriteKeys, | 146 | reactivityKeys: sourceWriteKeys, |
| 142 | }); | 147 | }); |
| 143 | placeholder.done(); | ||
| 144 | |||
| 145 | if (Exit.isFailure(exit)) { | 148 | if (Exit.isFailure(exit)) { |
| 146 | setAddError("Failed to add source"); | 149 | setAddError(errorMessageFromExit(exit, "Failed to add source")); |
| 147 | setAdding(false); | 150 | setAdding(false); |
| 148 | return; | 151 | return; |
| 149 | } | 152 | } |
| 150 | |||
| 151 | props.onComplete(); | 153 | props.onComplete(); |
| 152 | }; | 154 | }; |
| 153 | 155 | ||
| @@ -1,4 +1,7 @@ | |||
| 1 | import type { ScopeId } from "@executor-js/sdk/core"; | 1 | import type { ScopeId } from "@executor-js/sdk/core"; |
| 2 | import * as Atom from "effect/unstable/reactivity/Atom"; | ||
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 4 | import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; | ||
| 2 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; | 5 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; |
| 3 | import { GraphqlClient } from "./client"; | 6 | import { GraphqlClient } from "./client"; |
| 4 | 7 | ||
| @@ -19,4 +22,31 @@ export const graphqlSourceAtom = (scopeId: ScopeId, namespace: string) => · 14 unmodified lines | |||
| 19 | 22 | ||
| 20 | export const addGraphqlSource = GraphqlClient.mutation("graphql", "addSource"); | 23 | export const addGraphqlSource = GraphqlClient.mutation("graphql", "addSource"); |
| 21 | 24 | ||
| 25 | export const addGraphqlSourceOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 26 | sourcesOptimisticAtom(scopeId).pipe( | ||
| 27 | Atom.optimisticFn({ | ||
| 28 | reducer: (current, arg) => | ||
| 29 | AsyncResult.map(current, (rows) => { | ||
| 30 | const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; | ||
| 31 | const source = { | ||
| 32 | id, | ||
| 33 | scopeId, | ||
| 34 | kind: "graphql", | ||
| 35 | pluginId: "graphql", | ||
| 36 | name: arg.payload.name ?? id, | ||
| 37 | url: arg.payload.endpoint, | ||
| 38 | canRemove: false, | ||
| 39 | canRefresh: false, | ||
| 40 | canEdit: false, | ||
| 41 | runtime: false, | ||
| 42 | }; | ||
| 43 | return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) => | ||
| 44 | a.name.localeCompare(b.name), | ||
| 45 | ); | ||
| 46 | }), | ||
| 47 | fn: addGraphqlSource, | ||
| 48 | }), | ||
| 49 | ), | ||
| 50 | ); | ||
| 51 | |||
| 22 | export const updateGraphqlSource = GraphqlClient.mutation("graphql", "updateSource"); | 52 | export const updateGraphqlSource = GraphqlClient.mutation("graphql", "updateSource"); |
| @@ -1,6 +1,8 @@ | |||
| 1 | import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; | 1 | import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | import { useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import { Exit, Option, Schema } from "effect"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 4 | 6 | ||
| 5 | import { useScope } from "@executor-js/react/api/scope-context"; | 7 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 6 | import { Button } from "@executor-js/react/components/button"; | 8 | import { Button } from "@executor-js/react/components/button"; |
| @@ -50,24 +52,19 @@ import { · 43 unmodified lines | |||
| 50 | 52 | ||
| 51 | type RemoteAuthMode = "none" | "header" | "oauth2"; | 53 | type RemoteAuthMode = "none" | "header" | "oauth2"; |
| 52 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 54 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 53 | import { usePendingSources } from "@executor-js/react/api/optimistic"; | 55 | import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; |
| 54 | import { probeMcpEndpoint, addMcpSource } from "./atoms"; | ||
| 55 | import { mcpPresets, type McpPreset } from "../sdk/presets"; | 56 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 56 | 57 | ||
| 57 | const PublicErrorMessage = Schema.Struct({ | 58 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| 58 | _tag: Schema.Literals(["McpConnectionError", "McpToolDiscoveryError", "McpOAuthError"]), | 59 | |
| 59 | message: Schema.String, | 60 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => |
| 60 | }); | 61 | Option.match( |
| 61 | 62 | Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)), | |
| 62 | const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => { | 63 | { |
| 63 | const error = Exit.findErrorOption(exit); | 64 | onNone: () => fallback, |
| 64 | if (Option.isNone(error)) return fallback; | 65 | onSome: ({ message }) => message, |
| 65 | const errorMessage = Schema.decodeUnknownOption(PublicErrorMessage)(error.value); | 66 | }, |
| 66 | return Option.match(errorMessage, { | 67 | ); |
| 67 | onNone: () => fallback, | ||
| 68 | onSome: (value) => value.message, | ||
| 69 | }); | ||
| 70 | }; | ||
| 71 | 68 | ||
| 72 | // --------------------------------------------------------------------------- | 69 | // --------------------------------------------------------------------------- |
| 73 | // Preset lookup | 70 | // Preset lookup |
| @@ -285,8 +282,7 @@ export default function AddMcpSource(props: { · 211 unmodified lines | |||
| 285 | 282 | ||
| 286 | const scopeId = useScope(); | 283 | const scopeId = useScope(); |
| 287 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); | 284 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); |
| 288 | const doAdd = useAtomSet(addMcpSource, { mode: "promiseExit" }); | 285 | const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { mode: "promiseExit" }); |
| 289 | const { beginAdd } = usePendingSources(); | ||
| 290 | const secretList = useSecretPickerSecrets(); | 286 | const secretList = useSecretPickerSecrets(); |
| 291 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ | 287 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 292 | popupName: "mcp-oauth", | 288 | popupName: "mcp-oauth", |
| @@ -361,7 +357,7 @@ export default function AddMcpSource(props: { · 68 unmodified lines | |||
| 361 | if (Exit.isFailure(exit)) { | 357 | if (Exit.isFailure(exit)) { |
| 362 | dispatch({ | 358 | dispatch({ |
| 363 | type: "probe-fail", | 359 | type: "probe-fail", |
| 364 | error: messageFromExit(exit, "Failed to connect"), | 360 | error: errorMessageFromExit(exit, "Failed to connect"), |
| 365 | }); | 361 | }); |
| 366 | return; | 362 | return; |
| 367 | } | 363 | } |
| @@ -482,13 +478,6 @@ export default function AddMcpSource(props: { · 114 unmodified lines | |||
| 482 | }; | 478 | }; |
| 483 | const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name; | 479 | const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name; |
| 484 | const slugNamespace = slugifyNamespace(remoteIdentity.namespace); | 480 | const slugNamespace = slugifyNamespace(remoteIdentity.namespace); |
| 485 | const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`; | ||
| 486 | const placeholder = beginAdd({ | ||
| 487 | id: placeholderId, | ||
| 488 | name: displayName, | ||
| 489 | kind: "mcp", | ||
| 490 | url: state.url.trim(), | ||
| 491 | }); | ||
| 492 | const exit = await doAdd({ | 481 | const exit = await doAdd({ |
| 493 | params: { scopeId }, | 482 | params: { scopeId }, |
| 494 | payload: { | 483 | payload: { |
| @@ -504,11 +493,10 @@ export default function AddMcpSource(props: { · 9 unmodified lines | |||
| 504 | }, | 493 | }, |
| 505 | reactivityKeys: sourceWriteKeys, | 494 | reactivityKeys: sourceWriteKeys, |
| 506 | }); | 495 | }); |
| 507 | placeholder.done(); | ||
| 508 | if (Exit.isFailure(exit)) { | 496 | if (Exit.isFailure(exit)) { |
| 509 | dispatch({ | 497 | dispatch({ |
| 510 | type: "add-fail", | 498 | type: "add-fail", |
| 511 | error: messageFromExit(exit, "Failed to add source"), | 499 | error: errorMessageFromExit(exit, "Failed to add source"), |
| 512 | }); | 500 | }); |
| 513 | return; | 501 | return; |
| 514 | } | 502 | } |
| @@ -525,7 +513,6 @@ export default function AddMcpSource(props: { · 10 unmodified lines | |||
| 525 | doAdd, | 513 | doAdd, |
| 526 | props, | 514 | props, |
| 527 | scopeId, | 515 | scopeId, |
| 528 | beginAdd, | ||
| 529 | ]); | 516 | ]); |
| 530 | 517 | ||
| 531 | // ---- Stdio actions ---- | 518 | // ---- Stdio actions ---- |
| @@ -560,12 +547,6 @@ export default function AddMcpSource(props: { · 28 unmodified lines | |||
| 560 | setStdioError(null); | 547 | setStdioError(null); |
| 561 | const displayName = stdioIdentity.name.trim() || cmd; | 548 | const displayName = stdioIdentity.name.trim() || cmd; |
| 562 | const slugNamespace = slugifyNamespace(stdioIdentity.namespace); | 549 | const slugNamespace = slugifyNamespace(stdioIdentity.namespace); |
| 563 | const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`; | ||
| 564 | const placeholder = beginAdd({ | ||
| 565 | id: placeholderId, | ||
| 566 | name: displayName, | ||
| 567 | kind: "mcp", | ||
| 568 | }); | ||
| 569 | const exit = await doAdd({ | 550 | const exit = await doAdd({ |
| 570 | params: { scopeId }, | 551 | params: { scopeId }, |
| 571 | payload: { | 552 | payload: { |
| @@ -578,14 +559,13 @@ export default function AddMcpSource(props: { · 6 unmodified lines | |||
| 578 | }, | 559 | }, |
| 579 | reactivityKeys: sourceWriteKeys, | 560 | reactivityKeys: sourceWriteKeys, |
| 580 | }); | 561 | }); |
| 581 | placeholder.done(); | ||
| 582 | if (Exit.isFailure(exit)) { | 562 | if (Exit.isFailure(exit)) { |
| 583 | setStdioError(messageFromExit(exit, "Failed to add source")); | 563 | setStdioError(errorMessageFromExit(exit, "Failed to add source")); |
| 584 | setStdioAdding(false); | 564 | setStdioAdding(false); |
| 585 | return; | 565 | return; |
| 586 | } | 566 | } |
| 587 | props.onComplete(); | 567 | props.onComplete(); |
| 588 | }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props, beginAdd]); | 568 | }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props]); |
| 589 | 569 | ||
| 590 | // ---- Render ---- | 570 | // ---- Render ---- |
| 591 | 571 | ||
| @@ -1,4 +1,7 @@ | |||
| 1 | import type { ScopeId } from "@executor-js/sdk/core"; | 1 | import type { ScopeId } from "@executor-js/sdk/core"; |
| 2 | import * as Atom from "effect/unstable/reactivity/Atom"; | ||
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 4 | import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; | ||
| 2 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; | 5 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; |
| 3 | import { McpClient } from "./client"; | 6 | import { McpClient } from "./client"; |
| 4 | 7 | ||
| @@ -19,6 +22,32 @@ export const mcpSourceAtom = (scopeId: ScopeId, namespace: string) => · 14 unmodified lines | |||
| 19 | 22 | ||
| 20 | export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint"); | 23 | export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint"); |
| 21 | export const addMcpSource = McpClient.mutation("mcp", "addSource"); | 24 | export const addMcpSource = McpClient.mutation("mcp", "addSource"); |
| 25 | export const addMcpSourceOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 26 | sourcesOptimisticAtom(scopeId).pipe( | ||
| 27 | Atom.optimisticFn({ | ||
| 28 | reducer: (current, arg) => | ||
| 29 | AsyncResult.map(current, (rows) => { | ||
| 30 | const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; | ||
| 31 | const source = { | ||
| 32 | id, | ||
| 33 | scopeId, | ||
| 34 | kind: "mcp", | ||
| 35 | pluginId: "mcp", | ||
| 36 | name: arg.payload.name ?? id, | ||
| 37 | ...(arg.payload.transport === "remote" ? { url: arg.payload.endpoint } : {}), | ||
| 38 | canRemove: false, | ||
| 39 | canRefresh: false, | ||
| 40 | canEdit: false, | ||
| 41 | runtime: false, | ||
| 42 | }; | ||
| 43 | return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) => | ||
| 44 | a.name.localeCompare(b.name), | ||
| 45 | ); | ||
| 46 | }), | ||
| 47 | fn: addMcpSource, | ||
| 48 | }), | ||
| 49 | ), | ||
| 50 | ); | ||
| 22 | export const removeMcpSource = McpClient.mutation("mcp", "removeSource"); | 51 | export const removeMcpSource = McpClient.mutation("mcp", "removeSource"); |
| 23 | export const refreshMcpSource = McpClient.mutation("mcp", "refreshSource"); | 52 | export const refreshMcpSource = McpClient.mutation("mcp", "refreshSource"); |
| 24 | export const updateMcpSource = McpClient.mutation("mcp", "updateSource"); | 53 | export const updateMcpSource = McpClient.mutation("mcp", "updateSource"); |
| @@ -16,7 +16,6 @@ import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/rea · 15 unmodified lines | |||
| 16 | // state without a refresh. | 16 | // state without a refresh. |
| 17 | const addSpecWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const; | 17 | const addSpecWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const; |
| 18 | const bindingWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const; | 18 | const bindingWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const; |
| 19 | import { usePendingSources } from "@executor-js/react/api/optimistic"; | ||
| 20 | import { HeadersList } from "@executor-js/react/plugins/headers-list"; | 19 | import { HeadersList } from "@executor-js/react/plugins/headers-list"; |
| 21 | import { | 20 | import { |
| 22 | HttpCredentialsEditor, | 21 | HttpCredentialsEditor, |
| @@ -67,7 +66,7 @@ import { SourceFavicon } from "@executor-js/react/components/source-favicon"; · 44 unmodified lines | |||
| 67 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; | 66 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; |
| 68 | import { Skeleton } from "@executor-js/react/components/skeleton"; | 67 | import { Skeleton } from "@executor-js/react/components/skeleton"; |
| 69 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; | 68 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 70 | import { addOpenApiSpec, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; | 69 | import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; |
| 71 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; | 70 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; |
| 72 | import { | 71 | import { |
| 73 | headerBindingSlot, | 72 | headerBindingSlot, |
| @@ -83,6 +82,9 @@ import { · 9 unmodified lines | |||
| 83 | type ServerVariable, | 82 | type ServerVariable, |
| 84 | } from "../sdk/types"; | 83 | } from "../sdk/types"; |
| 85 | 84 | ||
| 85 | export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; | ||
| 86 | export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback"; | ||
| 87 | |||
| 86 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | 88 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| 87 | 89 | ||
| 88 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | 90 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => |
| @@ -94,9 +96,6 @@ const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: strin · 5 unmodified lines | |||
| 94 | }, | 96 | }, |
| 95 | ); | 97 | ); |
| 96 | 98 | ||
| 97 | export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; | ||
| 98 | export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback"; | ||
| 99 | |||
| 100 | const substituteUrlVariables = (url: string, values: Record<string, string>): string => { | 99 | const substituteUrlVariables = (url: string, values: Record<string, string>): string => { |
| 101 | let out = url; | 100 | let out = url; |
| 102 | for (const [name, value] of Object.entries(values)) { | 101 | for (const [name, value] of Object.entries(values)) { |
| @@ -122,15 +121,28 @@ export const openApiOAuthConnectionId = ( · 19 unmodified lines | |||
| 122 | */ | 121 | */ |
| 123 | export function resolveOAuthUrl(url: string, baseUrl: string): string { | 122 | export function resolveOAuthUrl(url: string, baseUrl: string): string { |
| 124 | if (!url) return url; | 123 | if (!url) return url; |
| 125 | if (URL.canParse(url)) { | 124 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs |
| 125 | try { | ||
| 126 | new URL(url); | ||
| 126 | return url; | 127 | return url; |
| 128 | } catch { | ||
| 129 | if (!baseUrl) return url; | ||
| 130 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs | ||
| 131 | try { | ||
| 132 | return new URL(url, baseUrl).toString(); | ||
| 133 | } catch { | ||
| 134 | return url; | ||
| 135 | } | ||
| 127 | } | 136 | } |
| 128 | if (!baseUrl || !URL.canParse(url, baseUrl)) return url; | ||
| 129 | return new URL(url, baseUrl).toString(); | ||
| 130 | } | 137 | } |
| 131 | 138 | ||
| 132 | export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { | 139 | export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { |
| 133 | return URL.canParse(authorizationUrl) ? new URL(authorizationUrl).origin : null; | 140 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs |
| 141 | try { | ||
| 142 | return new URL(authorizationUrl).origin; | ||
| 143 | } catch { | ||
| 144 | return null; | ||
| 145 | } | ||
| 134 | } | 146 | } |
| 135 | 147 | ||
| 136 | type StrategySelection = | 148 | type StrategySelection = |
| @@ -246,10 +258,9 @@ export default function AddOpenApiSource(props: { · 109 unmodified lines | |||
| 246 | const scopeId = useScope(); | 258 | const scopeId = useScope(); |
| 247 | const userScope = useUserScope(); | 259 | const userScope = useUserScope(); |
| 248 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); | 260 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); |
| 249 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); | 261 | const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { mode: "promiseExit" }); |
| 250 | const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); | 262 | const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); |
| 251 | const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" }); | 263 | const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" }); |
| 252 | const { beginAdd } = usePendingSources(); | ||
| 253 | const secretList = useSecretPickerSecrets(); | 264 | const secretList = useSecretPickerSecrets(); |
| 254 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ | 265 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 255 | popupName: OPENAPI_OAUTH_POPUP_NAME, | 266 | popupName: OPENAPI_OAUTH_POPUP_NAME, |
| @@ -281,7 +292,7 @@ export default function AddOpenApiSource(props: { · 25 unmodified lines | |||
| 281 | selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null; | 292 | selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null; |
| 282 | 293 | ||
| 283 | const serverVariables: Record<string, ServerVariable> = selectedServer | 294 | const serverVariables: Record<string, ServerVariable> = selectedServer |
| 284 | ? Option.getOrElse(selectedServer.variables, () => ({})) | 295 | ? Option.getOrElse(selectedServer.variables, () => ({}) as Record<string, ServerVariable>) |
| 285 | : {}; | 296 | : {}; |
| 286 | const serverVariableEntries: Array<[string, ServerVariable]> = Object.entries(serverVariables); | 297 | const serverVariableEntries: Array<[string, ServerVariable]> = Object.entries(serverVariables); |
| 287 | 298 | ||
| @@ -293,7 +304,10 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines | |||
| 293 | // Helper used by analyze + server selection: build a default selection map | 304 | // Helper used by analyze + server selection: build a default selection map |
| 294 | // from a server's variable defaults. | 305 | // from a server's variable defaults. |
| 295 | const defaultSelectionsFor = (server: ServerInfo): Record<string, string> => { | 306 | const defaultSelectionsFor = (server: ServerInfo): Record<string, string> => { |
| 296 | const vars: Record<string, ServerVariable> = Option.getOrElse(server.variables, () => ({})); | 307 | const vars: Record<string, ServerVariable> = Option.getOrElse( |
| 308 | server.variables, | ||
| 309 | () => ({}) as Record<string, ServerVariable>, | ||
| 310 | ); | ||
| 297 | const out: Record<string, string> = {}; | 311 | const out: Record<string, string> = {}; |
| 298 | for (const [name, v] of Object.entries(vars)) out[name] = v.default; | 312 | for (const [name, v] of Object.entries(vars)) out[name] = v.default; |
| 299 | return out; | 313 | return out; |
| @@ -384,33 +398,32 @@ export default function AddOpenApiSource(props: { · 84 unmodified lines | |||
| 384 | setAnalyzing(false); | 398 | setAnalyzing(false); |
| 385 | return; | 399 | return; |
| 386 | } | 400 | } |
| 387 | |||
| 388 | const result = exit.value; | 401 | const result = exit.value; |
| 389 | setPreview(result); | 402 | setPreview(result); |
| 390 | 403 | ||
| 391 | const firstServer = result.servers[0]; | 404 | const firstServer = result.servers[0]; |
| 392 | if (firstServer) { | 405 | if (firstServer) { |
| 393 | setSelectedServerIndex(0); | 406 | setSelectedServerIndex(0); |
| 394 | setVariableSelections(defaultSelectionsFor(firstServer)); | 407 | setVariableSelections(defaultSelectionsFor(firstServer)); |
| 395 | setCustomBaseUrl(""); | 408 | setCustomBaseUrl(""); |
| 396 | } else { | 409 | } else { |
| 397 | setSelectedServerIndex(-1); | 410 | setSelectedServerIndex(-1); |
| 398 | setVariableSelections({}); | 411 | setVariableSelections({}); |
| 399 | setCustomBaseUrl(""); | 412 | setCustomBaseUrl(""); |
| 400 | } | 413 | } |
| 401 | 414 | ||
| 402 | const firstPreset = result.headerPresets[0]; | 415 | const firstPreset = result.headerPresets[0]; |
| 403 | if (firstPreset) { | 416 | if (firstPreset) { |
| 404 | setStrategy({ kind: "header", presetIndex: 0 }); | 417 | setStrategy({ kind: "header", presetIndex: 0 }); |
| 405 | setCustomHeaders(entriesFromSpecPreset(firstPreset)); | 418 | setCustomHeaders(entriesFromSpecPreset(firstPreset)); |
| 406 | } else { | 419 | } else { |
| 407 | // No header presets — default to "custom" so the headers editor is | 420 | // No header presets — default to "custom" so the headers editor is |
| 408 | // visible immediately. Specs with no `security` block (e.g. Microsoft | 421 | // visible immediately. Specs with no `security` block (e.g. Microsoft |
| 409 | // Graph) would otherwise leave the user staring at just the | 422 | // Graph) would otherwise leave the user staring at just the |
| 410 | // Authentication heading with no way to add headers. | 423 | // Authentication heading with no way to add headers. |
| 411 | setStrategy({ kind: "custom" }); | 424 | setStrategy({ kind: "custom" }); |
| 412 | setCustomHeaders([]); | 425 | setCustomHeaders([]); |
| 413 | } | 426 | } |
| 414 | setAnalyzing(false); | 427 | setAnalyzing(false); |
| 415 | }; | 428 | }; |
| 416 | 429 | ||
| @@ -477,59 +490,59 @@ export default function AddOpenApiSource(props: { · 60 unmodified lines | |||
| 477 | const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); | 490 | const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); |
| 478 | 491 | ||
| 479 | if (selectedOAuth2Preset.flow === "clientCredentials") { | 492 | if (selectedOAuth2Preset.flow === "clientCredentials") { |
| 480 | // RFC 6749 §4.4: no user-interactive consent step. The client_secret | 493 | // RFC 6749 §4.4: no user-interactive consent step. The client_secret |
| 481 | // is mandatory; the backend exchanges tokens inline and returns a | 494 | // is mandatory; the backend exchanges tokens inline and returns a |
| 482 | // completed OAuth2Auth we can attach to the source directly. | 495 | // completed OAuth2Auth we can attach to the source directly. |
| 483 | if (!oauth2ClientSecretSecretId) { | 496 | if (!oauth2ClientSecretSecretId) { |
| 484 | setOauth2Error("client_credentials requires a client secret"); | 497 | setOauth2Error("client_credentials requires a client secret"); |
| 485 | return; | 498 | return; |
| 486 | } | 499 | } |
| 487 | setStartingOAuth(true); | 500 | setStartingOAuth(true); |
| 488 | const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow); | 501 | const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow); |
| 489 | const startOAuthExit = await doStartOAuth({ | 502 | const exit = await doStartOAuth({ |
| 490 | params: { scopeId }, | 503 | params: { scopeId }, |
| 491 | payload: { | 504 | payload: { |
| 492 | endpoint: tokenUrl, | 505 | endpoint: tokenUrl, |
| 493 | redirectUrl: tokenUrl, | 506 | redirectUrl: tokenUrl, |
| 494 | connectionId, | 507 | connectionId, |
| 495 | tokenScope: scopeId, | 508 | tokenScope: scopeId, |
| 496 | strategy: { | 509 | strategy: { |
| 497 | kind: "client-credentials", | 510 | kind: "client-credentials", |
| 498 | tokenEndpoint: tokenUrl, | 511 | tokenEndpoint: tokenUrl, |
| 512 | clientIdSecretId: oauth2ClientIdSecretId, | ||
| 513 | clientSecretSecretId: oauth2ClientSecretSecretId, | ||
| 514 | scopes: [...oauth2SelectedScopes], | ||
| 515 | }, | ||
| 516 | pluginId: "openapi", | ||
| 517 | identityLabel: `${displayName} OAuth`, | ||
| 518 | }, | ||
| 519 | }); | ||
| 520 | setStartingOAuth(false); | ||
| 521 | if (Exit.isFailure(exit)) { | ||
| 522 | setOauth2Error(errorMessageFromExit(exit, "Failed to start OAuth")); | ||
| 523 | return; | ||
| 524 | } | ||
| 525 | const response = exit.value; | ||
| 526 | if (!response.completedConnection) { | ||
| 527 | setOauth2Error("client_credentials flow did not mint a connection"); | ||
| 528 | return; | ||
| 529 | } | ||
| 530 | setOauth2AuthState({ | ||
| 531 | fingerprint: selectedOAuth2Fingerprint, | ||
| 532 | auth: new OAuth2Auth({ | ||
| 533 | kind: "oauth2", | ||
| 534 | connectionId: response.completedConnection.connectionId, | ||
| 535 | securitySchemeName: selectedOAuth2Preset.securitySchemeName, | ||
| 536 | flow: "clientCredentials", | ||
| 537 | tokenUrl, | ||
| 538 | authorizationUrl: null, | ||
| 499 | clientIdSecretId: oauth2ClientIdSecretId, | 539 | clientIdSecretId: oauth2ClientIdSecretId, |
| 500 | clientSecretSecretId: oauth2ClientSecretSecretId, | 540 | clientSecretSecretId: oauth2ClientSecretSecretId, |
| 501 | scopes: [...oauth2SelectedScopes], | 541 | scopes: [...oauth2SelectedScopes], |
| 502 | }, | 542 | }), |
| 503 | pluginId: "openapi", | 543 | }); |
| 504 | identityLabel: `${displayName} OAuth`, | 544 | setOauth2Error(null); |
| 505 | }, | ||
| 506 | }); | ||
| 507 | setStartingOAuth(false); | ||
| 508 | if (Exit.isFailure(startOAuthExit)) { | ||
| 509 | setOauth2Error(errorMessageFromExit(startOAuthExit, "Failed to start OAuth")); | ||
| 510 | return; | ||
| 511 | } | ||
| 512 | const response = startOAuthExit.value; | ||
| 513 | if (!response.completedConnection) { | ||
| 514 | setOauth2Error("client_credentials flow did not mint a connection"); | ||
| 515 | return; | 545 | return; |
| 516 | } | ||
| 517 | setOauth2AuthState({ | ||
| 518 | fingerprint: selectedOAuth2Fingerprint, | ||
| 519 | auth: new OAuth2Auth({ | ||
| 520 | kind: "oauth2", | ||
| 521 | connectionId: response.completedConnection.connectionId, | ||
| 522 | securitySchemeName: selectedOAuth2Preset.securitySchemeName, | ||
| 523 | flow: "clientCredentials", | ||
| 524 | tokenUrl, | ||
| 525 | authorizationUrl: null, | ||
| 526 | clientIdSecretId: oauth2ClientIdSecretId, | ||
| 527 | clientSecretSecretId: oauth2ClientSecretSecretId, | ||
| 528 | scopes: [...oauth2SelectedScopes], | ||
| 529 | }), | ||
| 530 | }); | ||
| 531 | setOauth2Error(null); | ||
| 532 | return; | ||
| 533 | } | 546 | } |
| 534 | 547 | ||
| 535 | const authorizationUrl = resolveOAuthUrl( | 548 | const authorizationUrl = resolveOAuthUrl( |
| @@ -537,61 +550,66 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | |||
| 537 | resolvedBaseUrl, | 550 | resolvedBaseUrl, |
| 538 | ); | 551 | ); |
| 539 | const issuerUrl = inferOAuthIssuerUrl(authorizationUrl); | 552 | const issuerUrl = inferOAuthIssuerUrl(authorizationUrl); |
| 540 | const startOAuthExit = await doStartOAuth({ | ||
| 541 | params: { scopeId }, | ||
| 542 | payload: { | ||
| 543 | endpoint: authorizationUrl, | ||
| 544 | connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow), | ||
| 545 | tokenScope: scopeId, | ||
| 546 | redirectUrl: oauth2RedirectUrl, | ||
| 547 | strategy: { | ||
| 548 | kind: "authorization-code", | ||
| 549 | authorizationEndpoint: authorizationUrl, | ||
| 550 | tokenEndpoint: tokenUrl, | ||
| 551 | issuerUrl, | ||
| 552 | clientIdSecretId: oauth2ClientIdSecretId, | ||
| 553 | clientSecretSecretId: oauth2ClientSecretSecretId ?? null, | ||
| 554 | scopes: [...oauth2SelectedScopes], | ||
| 555 | }, | ||
| 556 | pluginId: "openapi", | ||
| 557 | identityLabel: `${displayName} OAuth`, | ||
| 558 | }, | ||
| 559 | }); | ||
| 560 | if (Exit.isFailure(startOAuthExit)) { | ||
| 561 | setOauth2Error(errorMessageFromExit(startOAuthExit, "Failed to start OAuth")); | ||
| 562 | return; | ||
| 563 | } | ||
| 564 | const response = startOAuthExit.value; | ||
| 565 | if (response.authorizationUrl === null) { | ||
| 566 | setOauth2Error("Unexpected response flow from server"); | ||
| 567 | return; | ||
| 568 | } | ||
| 569 | 553 | ||
| 570 | await oauth.openAuthorization({ | 554 | await oauth.openAuthorization({ |
| 571 | run: async () => ({ | 555 | run: async () => { |
| 572 | sessionId: response.sessionId, | 556 | const exit = await doStartOAuth({ |
| 573 | authorizationUrl: response.authorizationUrl, | 557 | params: { scopeId }, |
| 574 | }), | 558 | payload: { |
| 575 | onSuccess: (result) => { | 559 | endpoint: authorizationUrl, |
| 576 | setOauth2AuthState({ | 560 | connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow), |
| 577 | fingerprint: selectedOAuth2Fingerprint, | 561 | tokenScope: scopeId, |
| 578 | auth: new OAuth2Auth({ | 562 | redirectUrl: oauth2RedirectUrl, |
| 579 | kind: "oauth2", | 563 | strategy: { |
| 580 | connectionId: result.connectionId, | 564 | kind: "authorization-code", |
| 581 | securitySchemeName: selectedOAuth2Preset.securitySchemeName, | 565 | authorizationEndpoint: authorizationUrl, |
| 582 | flow: "authorizationCode", | 566 | tokenEndpoint: tokenUrl, |
| 583 | tokenUrl, | 567 | issuerUrl, |
| 584 | authorizationUrl, | 568 | clientIdSecretId: oauth2ClientIdSecretId, |
| 585 | issuerUrl, | 569 | clientSecretSecretId: oauth2ClientSecretSecretId ?? null, |
| 586 | clientIdSecretId: oauth2ClientIdSecretId, | 570 | scopes: [...oauth2SelectedScopes], |
| 587 | clientSecretSecretId: oauth2ClientSecretSecretId, | 571 | }, |
| 588 | scopes: [...oauth2SelectedScopes], | 572 | pluginId: "openapi", |
| 589 | }), | 573 | identityLabel: `${displayName} OAuth`, |
| 574 | }, | ||
| 590 | }); | 575 | }); |
| 591 | setOauth2Error(null); | 576 | if (Exit.isFailure(exit)) { |
| 592 | }, | 577 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() |
| 593 | onError: setOauth2Error, | 578 | throw new Error(errorMessageFromExit(exit, "Failed to start OAuth")); |
| 594 | }); | 579 | } |
| 580 | const response = exit.value; | ||
| 581 | if (response.authorizationUrl === null) { | ||
| 582 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() | ||
| 583 | throw new Error("Unexpected response flow from server"); | ||
| 584 | } | ||
| 585 | return { | ||
| 586 | sessionId: response.sessionId, | ||
| 587 | authorizationUrl: response.authorizationUrl, | ||
| 588 | }; | ||
| 589 | }, | ||
| 590 | onSuccess: (result) => { | ||
| 591 | setOauth2AuthState({ | ||
| 592 | fingerprint: selectedOAuth2Fingerprint, | ||
| 593 | auth: new OAuth2Auth({ | ||
| 594 | kind: "oauth2", | ||
| 595 | connectionId: result.connectionId, | ||
| 596 | securitySchemeName: selectedOAuth2Preset.securitySchemeName, | ||
| 597 | flow: "authorizationCode", | ||
| 598 | tokenUrl, | ||
| 599 | authorizationUrl, | ||
| 600 | issuerUrl, | ||
| 601 | clientIdSecretId: oauth2ClientIdSecretId, | ||
| 602 | clientSecretSecretId: oauth2ClientSecretSecretId, | ||
| 603 | scopes: [...oauth2SelectedScopes], | ||
| 604 | }), | ||
| 605 | }); | ||
| 606 | setOauth2Error(null); | ||
| 607 | }, | ||
| 608 | onError: (message) => { | ||
| 609 | setStartingOAuth(false); | ||
| 610 | setOauth2Error(message); | ||
| 611 | }, | ||
| 612 | }); | ||
| 595 | }, [ | 613 | }, [ |
| 596 | selectedOAuth2Preset, | 614 | selectedOAuth2Preset, |
| 597 | oauth2ClientIdSecretId, | 615 | oauth2ClientIdSecretId, |
| @@ -621,24 +639,13 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines | |||
| 621 | const displayName = | 639 | const displayName = |
| 622 | identity.name.trim() || | 640 | identity.name.trim() || |
| 623 | (preview ? Option.getOrElse(preview.title, () => namespace) : namespace); | 641 | (preview ? Option.getOrElse(preview.title, () => namespace) : namespace); |
| 624 | const placeholder = beginAdd({ | 642 | const exit = await doAdd({ |
| 625 | id: namespace, | ||
| 626 | name: displayName, | ||
| 627 | kind: "openapi", | ||
| 628 | url: resolvedBaseUrl || undefined, | ||
| 629 | }); | ||
| 630 | const failAdd = (message: string) => { | ||
| 631 | setAddError(message); | ||
| 632 | setAdding(false); | ||
| 633 | placeholder.done(); | ||
| 634 | }; | ||
| 635 | const resultExit = await doAdd({ | ||
| 636 | params: { scopeId }, | 643 | params: { scopeId }, |
| 637 | payload: { | 644 | payload: { |
| 638 | spec: specUrl, | 645 | spec: specUrl, |
| 639 | specFetchCredentials: serializeHttpCredentials(specFetchCredentials), | 646 | specFetchCredentials: serializeHttpCredentials(specFetchCredentials), |
| 640 | name: identity.name.trim() || undefined, | 647 | name: displayName, |
| 641 | namespace: slugifyNamespace(identity.namespace) || undefined, | 648 | namespace, |
| 642 | baseUrl: resolvedBaseUrl || undefined, | 649 | baseUrl: resolvedBaseUrl || undefined, |
| 643 | ...(hasHeaders ? { headers: configuredHeaders } : {}), | 650 | ...(hasHeaders ? { headers: configuredHeaders } : {}), |
| 644 | ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0 | 651 | ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0 |
| @@ -648,13 +655,13 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | |||
| 648 | }, | 655 | }, |
| 649 | reactivityKeys: addSpecWriteKeys, | 656 | reactivityKeys: addSpecWriteKeys, |
| 650 | }); | 657 | }); |
| 651 | 658 | if (Exit.isFailure(exit)) { | |
| 652 | if (Exit.isFailure(resultExit)) { | 659 | setAddError(errorMessageFromExit(exit, "Failed to add source")); |
| 653 | failAdd(errorMessageFromExit(resultExit, "Failed to add source")); | 660 | setAdding(false); |
| 654 | return; | 661 | return; |
| 655 | } | 662 | } |
| 656 | 663 | ||
| 657 | const sourceId = resultExit.value.namespace; | 664 | const sourceId = exit.value.namespace; |
| 658 | const sourceScope = ScopeId.make(scopeId); | 665 | const sourceScope = ScopeId.make(scopeId); |
| 659 | const bindingScope = ScopeId.make(userScope); | 666 | const bindingScope = ScopeId.make(userScope); |
| 660 | 667 | ||
| @@ -674,13 +681,14 @@ export default function AddOpenApiSource(props: { · 13 unmodified lines | |||
| 674 | reactivityKeys: bindingWriteKeys, | 681 | reactivityKeys: bindingWriteKeys, |
| 675 | }); | 682 | }); |
| 676 | if (Exit.isFailure(bindingExit)) { | 683 | if (Exit.isFailure(bindingExit)) { |
| 677 | failAdd(errorMessageFromExit(bindingExit, "Failed to add source")); | 684 | setAddError(errorMessageFromExit(bindingExit, "Failed to add source")); |
| 685 | setAdding(false); | ||
| 678 | return; | 686 | return; |
| 679 | } | 687 | } |
| 680 | } | 688 | } |
| 681 | 689 | ||
| 682 | if (configuredOAuth2 && oauth2ClientIdSecretId) { | 690 | if (configuredOAuth2 && oauth2ClientIdSecretId) { |
| 683 | const clientIdBindingExit = await doSetBinding({ | 691 | const bindingExit = await doSetBinding({ |
| 684 | params: { scopeId }, | 692 | params: { scopeId }, |
| 685 | payload: { | 693 | payload: { |
| 686 | sourceId, | 694 | sourceId, |
| @@ -694,14 +702,15 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines | |||
| 694 | }, | 702 | }, |
| 695 | reactivityKeys: bindingWriteKeys, | 703 | reactivityKeys: bindingWriteKeys, |
| 696 | }); | 704 | }); |
| 697 | if (Exit.isFailure(clientIdBindingExit)) { | 705 | if (Exit.isFailure(bindingExit)) { |
| 698 | failAdd(errorMessageFromExit(clientIdBindingExit, "Failed to add source")); | 706 | setAddError(errorMessageFromExit(bindingExit, "Failed to add source")); |
| 707 | setAdding(false); | ||
| 699 | return; | 708 | return; |
| 700 | } | 709 | } |
| 701 | } | 710 | } |
| 702 | 711 | ||
| 703 | if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) { | 712 | if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) { |
| 704 | const clientSecretBindingExit = await doSetBinding({ | 713 | const bindingExit = await doSetBinding({ |
| 705 | params: { scopeId }, | 714 | params: { scopeId }, |
| 706 | payload: { | 715 | payload: { |
| 707 | sourceId, | 716 | sourceId, |
| @@ -715,14 +724,15 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines | |||
| 715 | }, | 724 | }, |
| 716 | reactivityKeys: bindingWriteKeys, | 725 | reactivityKeys: bindingWriteKeys, |
| 717 | }); | 726 | }); |
| 718 | if (Exit.isFailure(clientSecretBindingExit)) { | 727 | if (Exit.isFailure(bindingExit)) { |
| 719 | failAdd(errorMessageFromExit(clientSecretBindingExit, "Failed to add source")); | 728 | setAddError(errorMessageFromExit(bindingExit, "Failed to add source")); |
| 729 | setAdding(false); | ||
| 720 | return; | 730 | return; |
| 721 | } | 731 | } |
| 722 | } | 732 | } |
| 723 | 733 | ||
| 724 | if (configuredOAuth2 && oauth2Auth) { | 734 | if (configuredOAuth2 && oauth2Auth) { |
| 725 | const connectionBindingExit = await doSetBinding({ | 735 | const bindingExit = await doSetBinding({ |
| 726 | params: { scopeId }, | 736 | params: { scopeId }, |
| 727 | payload: { | 737 | payload: { |
| 728 | sourceId, | 738 | sourceId, |
| @@ -736,13 +746,13 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines | |||
| 736 | }, | 746 | }, |
| 737 | reactivityKeys: bindingWriteKeys, | 747 | reactivityKeys: bindingWriteKeys, |
| 738 | }); | 748 | }); |
| 739 | if (Exit.isFailure(connectionBindingExit)) { | 749 | if (Exit.isFailure(bindingExit)) { |
| 740 | failAdd(errorMessageFromExit(connectionBindingExit, "Failed to add source")); | 750 | setAddError(errorMessageFromExit(bindingExit, "Failed to add source")); |
| 751 | setAdding(false); | ||
| 741 | return; | 752 | return; |
| 742 | } | 753 | } |
| 743 | } | 754 | } |
| 744 | 755 | ||
| 745 | placeholder.done(); | ||
| 746 | props.onComplete(); | 756 | props.onComplete(); |
| 747 | }; | 757 | }; |
| 748 | 758 | ||
| @@ -1,4 +1,7 @@ | |||
| 1 | import type { ScopeId } from "@executor-js/sdk/core"; | 1 | import type { ScopeId } from "@executor-js/sdk/core"; |
| 2 | import * as Atom from "effect/unstable/reactivity/Atom"; | ||
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 4 | import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; | ||
| 2 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; | 5 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; |
| 3 | import { OpenApiClient } from "./client"; | 6 | import { OpenApiClient } from "./client"; |
| 4 | 7 | ||
| @@ -32,6 +35,33 @@ export const previewOpenApiSpec = OpenApiClient.mutation("openapi", "previewSpec · 27 unmodified lines | |||
| 32 | 35 | ||
| 33 | export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec"); | 36 | export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec"); |
| 34 | 37 | ||
| 38 | export const addOpenApiSpecOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 39 | sourcesOptimisticAtom(scopeId).pipe( | ||
| 40 | Atom.optimisticFn({ | ||
| 41 | reducer: (current, arg) => | ||
| 42 | AsyncResult.map(current, (rows) => { | ||
| 43 | const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; | ||
| 44 | const source = { | ||
| 45 | id, | ||
| 46 | scopeId, | ||
| 47 | kind: "openapi", | ||
| 48 | pluginId: "openapi", | ||
| 49 | name: arg.payload.name ?? id, | ||
| 50 | ...(arg.payload.baseUrl ? { url: arg.payload.baseUrl } : {}), | ||
| 51 | canRemove: false, | ||
| 52 | canRefresh: false, | ||
| 53 | canEdit: false, | ||
| 54 | runtime: false, | ||
| 55 | }; | ||
| 56 | return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) => | ||
| 57 | a.name.localeCompare(b.name), | ||
| 58 | ); | ||
| 59 | }), | ||
| 60 | fn: addOpenApiSpec, | ||
| 61 | }), | ||
| 62 | ), | ||
| 63 | ); | ||
| 64 | |||
| 35 | export const updateOpenApiSource = OpenApiClient.mutation("openapi", "updateSource"); | 65 | export const updateOpenApiSource = OpenApiClient.mutation("openapi", "updateSource"); |
| 36 | 66 | ||
| 37 | export const setOpenApiSourceBinding = OpenApiClient.mutation("openapi", "setSourceBinding"); | 67 | export const setOpenApiSourceBinding = OpenApiClient.mutation("openapi", "setSourceBinding"); |
| @@ -55,7 +55,10 @@ export const sourcesAtom = (scopeId: ScopeId) => · 54 unmodified lines | |||
| 55 | 55 | ||
| 56 | /** Single source by id — derived from the sources list */ | 56 | /** Single source by id — derived from the sources list */ |
| 57 | export const sourceAtom = (sourceId: string, scopeId: ScopeId) => | 57 | export const sourceAtom = (sourceId: string, scopeId: ScopeId) => |
| 58 | Atom.mapResult(sourcesAtom(scopeId), (sources) => sources.find((s) => s.id === sourceId) ?? null); | 58 | Atom.mapResult( |
| 59 | sourcesOptimisticAtom(scopeId), | ||
| 60 | (sources) => sources.find((s) => s.id === sourceId) ?? null, | ||
| 61 | ); | ||
| 59 | 62 | ||
| 60 | export const secretsAtom = (scopeId: ScopeId) => | 63 | export const secretsAtom = (scopeId: ScopeId) => |
| 61 | ExecutorApiClient.query("secrets", "list", { | 64 | ExecutorApiClient.query("secrets", "list", { |
| @@ -85,25 +88,14 @@ export const secretUsagesAtom = (scopeId: ScopeId, secretId: SecretId) => · 23 unmodified lines | |||
| 85 | // Refresh whenever any source / connection / secret changes — adding | 88 | // Refresh whenever any source / connection / secret changes — adding |
| 86 | // an oauth source pulls in a new connection-secret link and we want | 89 | // an oauth source pulls in a new connection-secret link and we want |
| 87 | // the usage list to reflect it. | 90 | // the usage list to reflect it. |
| 88 | reactivityKeys: [ | 91 | reactivityKeys: [ReactivityKey.secrets, ReactivityKey.sources, ReactivityKey.connections], |
| 89 | ReactivityKey.secrets, | ||
| 90 | ReactivityKey.sources, | ||
| 91 | ReactivityKey.connections, | ||
| 92 | ], | ||
| 93 | }); | 92 | }); |
| 94 | 93 | ||
| 95 | export const connectionUsagesAtom = ( | 94 | export const connectionUsagesAtom = (scopeId: ScopeId, connectionId: ConnectionId) => |
| 96 | scopeId: ScopeId, | ||
| 97 | connectionId: ConnectionId, | ||
| 98 | ) => | ||
| 99 | ExecutorApiClient.query("connections", "usages", { | 95 | ExecutorApiClient.query("connections", "usages", { |
| 100 | params: { scopeId, connectionId }, | 96 | params: { scopeId, connectionId }, |
| 101 | timeToLive: "30 seconds", | 97 | timeToLive: "30 seconds", |
| 102 | reactivityKeys: [ | 98 | reactivityKeys: [ReactivityKey.connections, ReactivityKey.sources, ReactivityKey.secrets], |
| 103 | ReactivityKey.connections, | ||
| 104 | ReactivityKey.sources, | ||
| 105 | ReactivityKey.secrets, | ||
| 106 | ], | ||
| 107 | }); | 99 | }); |
| 108 | 100 | ||
| 109 | export const policiesAtom = (scopeId: ScopeId) => | 101 | export const policiesAtom = (scopeId: ScopeId) => |
| @@ -153,6 +145,46 @@ export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); · 43 unmodified lines | |||
| 153 | 145 | ||
| 154 | export const removePolicy = ExecutorApiClient.mutation("policies", "remove"); | 146 | export const removePolicy = ExecutorApiClient.mutation("policies", "remove"); |
| 155 | 147 | ||
| 148 | // --------------------------------------------------------------------------- | ||
| 149 | // Sources — optimistic surface. | ||
| 150 | // --------------------------------------------------------------------------- | ||
| 151 | |||
| 152 | export const sourcesOptimisticAtom = Atom.family((scopeId: ScopeId) => | ||
| 153 | Atom.optimistic(sourcesAtom(scopeId)), | ||
| 154 | ); | ||
| 155 | |||
| 156 | export const removeSourceOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 157 | sourcesOptimisticAtom(scopeId).pipe( | ||
| 158 | Atom.optimisticFn({ | ||
| 159 | reducer: (current, arg) => | ||
| 160 | AsyncResult.map(current, (rows) => | ||
| 161 | rows.filter((source) => source.id !== arg.params.sourceId), | ||
| 162 | ), | ||
| 163 | fn: removeSource, | ||
| 164 | }), | ||
| 165 | ), | ||
| 166 | ); | ||
| 167 | |||
| 168 | // --------------------------------------------------------------------------- | ||
| 169 | // Connections — optimistic removals. | ||
| 170 | // --------------------------------------------------------------------------- | ||
| 171 | |||
| 172 | export const connectionsOptimisticAtom = Atom.family((scopeId: ScopeId) => | ||
| 173 | Atom.optimistic(connectionsAtom(scopeId)), | ||
| 174 | ); | ||
| 175 | |||
| 176 | export const removeConnectionOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 177 | connectionsOptimisticAtom(scopeId).pipe( | ||
| 178 | Atom.optimisticFn({ | ||
| 179 | reducer: (current, arg) => | ||
| 180 | AsyncResult.map(current, (rows) => | ||
| 181 | rows.filter((connection) => connection.id !== arg.params.connectionId), | ||
| 182 | ), | ||
| 183 | fn: removeConnection, | ||
| 184 | }), | ||
| 185 | ), | ||
| 186 | ); | ||
| 187 | |||
| 156 | // --------------------------------------------------------------------------- | 188 | // --------------------------------------------------------------------------- |
| 157 | // Secrets — optimistic removals. | 189 | // Secrets — optimistic removals. |
| 158 | // --------------------------------------------------------------------------- | 190 | // --------------------------------------------------------------------------- |
| @@ -1,213 +0,0 @@ | |||
| 1 | import * as React from "react"; | ||
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| 3 | import * as Atom from "effect/unstable/reactivity/Atom"; | ||
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 5 | import type { ScopeId } from "@executor-js/sdk"; | ||
| 6 | |||
| 7 | import { connectionsAtom, sourcesAtom } from "./atoms"; | ||
| 8 | |||
| 9 | /** | ||
| 10 | * Client-only optimistic-update layer. | ||
| 11 | * | ||
| 12 | * Mutations that add a row return only after the server roundtrip. The | ||
| 13 | * `pendingResourceAtom` family lets a UI insert a placeholder *before* the | ||
| 14 | * mutation resolves so the sidebar/list updates instantly. Once the server | ||
| 15 | * returns and reactivity refetches the canonical list, callers should remove | ||
| 16 | * the placeholder (the dedupe in `mergePending` filters it anyway, but | ||
| 17 | * explicit removal keeps the pending set bounded on errors). | ||
| 18 | * | ||
| 19 | * Resource keys (`"sources"`, `"secrets"`, …) match the strings in | ||
| 20 | * `ReactivityKey` but are intentionally not coupled — pending state is purely | ||
| 21 | * client-side. | ||
| 22 | */ | ||
| 23 | |||
| 24 | export const PendingResource = { | ||
| 25 | sources: "sources", | ||
| 26 | connectionRemovals: "connection-removals", | ||
| 27 | } as const; | ||
| 28 | |||
| 29 | export interface PendingEntry<T> { | ||
| 30 | readonly id: string; | ||
| 31 | readonly value: T; | ||
| 32 | } | ||
| 33 | |||
| 34 | const pendingFamily = Atom.family((_resource: string) => | ||
| 35 | Atom.make<ReadonlyArray<PendingEntry<unknown>>>([]), | ||
| 36 | ); | ||
| 37 | |||
| 38 | const atomFor = <T,>(resource: string) => | ||
| 39 | pendingFamily(resource) as ReturnType<typeof pendingFamily> & { | ||
| 40 | readonly __t?: T; | ||
| 41 | }; | ||
| 42 | |||
| 43 | export const usePendingResource = <T,>(resource: string) => { | ||
| 44 | const atom = atomFor<T>(resource); | ||
| 45 | const pending = useAtomValue(atom) as ReadonlyArray<PendingEntry<T>>; | ||
| 46 | const setPending = useAtomSet(atom); | ||
| 47 | |||
| 48 | const add = React.useCallback( | ||
| 49 | (entry: PendingEntry<T>) => | ||
| 50 | setPending((prev) => [ | ||
| 51 | ...(prev as ReadonlyArray<PendingEntry<T>>).filter((p) => p.id !== entry.id), | ||
| 52 | entry, | ||
| 53 | ]), | ||
| 54 | [setPending], | ||
| 55 | ); | ||
| 56 | |||
| 57 | const remove = React.useCallback( | ||
| 58 | (id: string) => | ||
| 59 | setPending((prev) => (prev as ReadonlyArray<PendingEntry<T>>).filter((p) => p.id !== id)), | ||
| 60 | [setPending], | ||
| 61 | ); | ||
| 62 | |||
| 63 | const clear = React.useCallback(() => setPending([]), [setPending]); | ||
| 64 | |||
| 65 | return { pending, add, remove, clear }; | ||
| 66 | }; | ||
| 67 | |||
| 68 | /** | ||
| 69 | * Merges pending entries with a server-loaded list. Pending entries whose id | ||
| 70 | * already exists on the server are dropped so we never double-render a row | ||
| 71 | * once the canonical version arrives. | ||
| 72 | */ | ||
| 73 | export const mergePending = <T, R>( | ||
| 74 | pending: ReadonlyArray<PendingEntry<T>>, | ||
| 75 | server: ReadonlyArray<R>, | ||
| 76 | serverId: (row: R) => string, | ||
| 77 | fromPending: (entry: PendingEntry<T>) => R, | ||
| 78 | ): ReadonlyArray<R> => { | ||
| 79 | if (pending.length === 0) return server; | ||
| 80 | const seen = new Set(server.map(serverId)); | ||
| 81 | const extras = pending.filter((p) => !seen.has(p.id)).map(fromPending); | ||
| 82 | if (extras.length === 0) return server; | ||
| 83 | return [...extras, ...server]; | ||
| 84 | }; | ||
| 85 | |||
| 86 | // --------------------------------------------------------------------------- | ||
| 87 | // Sources — convenience wrappers used by sidebar/list views and add forms. | ||
| 88 | // Pending entries are global (not per-scope) because the user only sees one | ||
| 89 | // scope at a time; this keeps the add-form code free of scope plumbing. | ||
| 90 | // --------------------------------------------------------------------------- | ||
| 91 | |||
| 92 | export interface PendingSource { | ||
| 93 | readonly name: string; | ||
| 94 | readonly kind: string; | ||
| 95 | readonly url?: string; | ||
| 96 | } | ||
| 97 | |||
| 98 | /** | ||
| 99 | * Sidebar/list helper. Reads `sourcesAtom(scopeId)` and merges any pending | ||
| 100 | * placeholder rows in, sorting the combined list by name so the placeholder | ||
| 101 | * lands in the same position the canonical row will occupy — no visual jump | ||
| 102 | * when the server confirms. | ||
| 103 | */ | ||
| 104 | export const useSourcesWithPending = (scopeId: ScopeId) => { | ||
| 105 | const result = useAtomValue(sourcesAtom(scopeId)); | ||
| 106 | const { pending } = usePendingResource<PendingSource>(PendingResource.sources); | ||
| 107 | return React.useMemo( | ||
| 108 | () => | ||
| 109 | AsyncResult.map( | ||
| 110 | result, | ||
| 111 | ( | ||
| 112 | sources: ReadonlyArray<{ | ||
| 113 | readonly id: string; | ||
| 114 | readonly name: string; | ||
| 115 | readonly kind: string; | ||
| 116 | readonly url?: string; | ||
| 117 | }>, | ||
| 118 | ) => { | ||
| 119 | const merged = mergePending( | ||
| 120 | pending, | ||
| 121 | sources, | ||
| 122 | (s: { readonly id: string }) => s.id, | ||
| 123 | (p) => ({ | ||
| 124 | id: p.id, | ||
| 125 | name: p.value.name, | ||
| 126 | kind: p.value.kind, | ||
| 127 | url: p.value.url, | ||
| 128 | // The placeholder cannot be removed/refreshed/edited until the | ||
| 129 | // server confirms it, so disable those affordances. | ||
| 130 | canRemove: false, | ||
| 131 | canRefresh: false, | ||
| 132 | canEdit: false, | ||
| 133 | }), | ||
| 134 | ); | ||
| 135 | return [...merged].sort((a, b) => a.name.localeCompare(b.name)); | ||
| 136 | }, | ||
| 137 | ), | ||
| 138 | [result, pending], | ||
| 139 | ); | ||
| 140 | }; | ||
| 141 | |||
| 142 | /** | ||
| 143 | * Hook for an add form. Returns helpers to push and clear a pending source | ||
| 144 | * placeholder. On submit: | ||
| 145 | * const placeholder = beginAdd({ id: namespace, name, kind }); | ||
| 146 | * try { await doAdd(...); } finally { placeholder.done(); } | ||
| 147 | */ | ||
| 148 | export const usePendingSources = () => { | ||
| 149 | const { add, remove } = usePendingResource<PendingSource>(PendingResource.sources); | ||
| 150 | return React.useMemo( | ||
| 151 | () => ({ | ||
| 152 | beginAdd: (entry: { id: string } & PendingSource) => { | ||
| 153 | add({ id: entry.id, value: { name: entry.name, kind: entry.kind, url: entry.url } }); | ||
| 154 | return { done: () => remove(entry.id) }; | ||
| 155 | }, | ||
| 156 | }), | ||
| 157 | [add, remove], | ||
| 158 | ); | ||
| 159 | }; | ||
| 160 | |||
| 161 | // --------------------------------------------------------------------------- | ||
| 162 | // Connections — optimistic removals. | ||
| 163 | // --------------------------------------------------------------------------- | ||
| 164 | |||
| 165 | interface PendingConnectionRemoval { | ||
| 166 | readonly id: string; | ||
| 167 | } | ||
| 168 | |||
| 169 | export const useConnectionsWithPendingRemovals = (scopeId: ScopeId) => { | ||
| 170 | const result = useAtomValue(connectionsAtom(scopeId)); | ||
| 171 | const { pending, remove } = usePendingResource<PendingConnectionRemoval>( | ||
| 172 | PendingResource.connectionRemovals, | ||
| 173 | ); | ||
| 174 | |||
| 175 | React.useEffect(() => { | ||
| 176 | if (!AsyncResult.isSuccess(result) || pending.length === 0) return; | ||
| 177 | |||
| 178 | const serverIds = new Set( | ||
| 179 | result.value.map((connection: { readonly id: string }) => connection.id), | ||
| 180 | ); | ||
| 181 | for (const entry of pending) { | ||
| 182 | if (!serverIds.has(entry.id)) remove(entry.id); | ||
| 183 | } | ||
| 184 | }, [result, pending, remove]); | ||
| 185 | |||
| 186 | return React.useMemo( | ||
| 187 | () => | ||
| 188 | AsyncResult.map(result, (connections) => { | ||
| 189 | if (pending.length === 0) return connections; | ||
| 190 | const hiddenIds = new Set(pending.map((entry) => entry.id)); | ||
| 191 | return connections.filter( | ||
| 192 | (connection: { readonly id: string }) => !hiddenIds.has(connection.id), | ||
| 193 | ); | ||
| 194 | }), | ||
| 195 | [result, pending], | ||
| 196 | ); | ||
| 197 | }; | ||
| 198 | |||
| 199 | export const usePendingConnectionRemovals = () => { | ||
| 200 | const { add, remove } = usePendingResource<PendingConnectionRemoval>( | ||
| 201 | PendingResource.connectionRemovals, | ||
| 202 | ); | ||
| 203 | |||
| 204 | return React.useMemo( | ||
| 205 | () => ({ | ||
| 206 | beginRemove: (id: string) => { | ||
| 207 | add({ id, value: { id } }); | ||
| 208 | return { undo: () => remove(id) }; | ||
| 209 | }, | ||
| 210 | }), | ||
| 211 | [add, remove], | ||
| 212 | ); | ||
| 213 | }; | ||
| @@ -4,7 +4,7 @@ import { useAtomValue } from "@effect/atom-react"; · 3 unmodified lines | |||
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | import { PlusIcon } from "lucide-react"; | 5 | import { PlusIcon } from "lucide-react"; |
| 6 | import { SourceFavicon } from "./source-favicon"; | 6 | import { SourceFavicon } from "./source-favicon"; |
| 7 | import { sourcesAtom } from "../api/atoms"; | 7 | import { sourcesOptimisticAtom } from "../api/atoms"; |
| 8 | import { useScope } from "../hooks/use-scope"; | 8 | import { useScope } from "../hooks/use-scope"; |
| 9 | import { useSourcePlugins } from "@executor-js/sdk/client"; | 9 | import { useSourcePlugins } from "@executor-js/sdk/client"; |
| 10 | import { | 10 | import { |
| @@ -32,7 +32,7 @@ export function CommandPalette() { · 21 unmodified lines | |||
| 32 | const [open, setOpen] = useState(false); | 32 | const [open, setOpen] = useState(false); |
| 33 | const navigate = useNavigate(); | 33 | const navigate = useNavigate(); |
| 34 | const scopeId = useScope(); | 34 | const scopeId = useScope(); |
| 35 | const sourcesResult = useAtomValue(sourcesAtom(scopeId)); | 35 | const sourcesResult = useAtomValue(sourcesOptimisticAtom(scopeId)); |
| 36 | 36 | ||
| 37 | // Toggle with ⌘K / Ctrl+K | 37 | // Toggle with ⌘K / Ctrl+K |
| 38 | useEffect(() => { | 38 | useEffect(() => { |
| @@ -7,8 +7,11 @@ import * as Schema from "effect/Schema"; · 6 unmodified lines | |||
| 7 | import { ConnectionId, ConnectionInUseError, type ScopeId } from "@executor-js/sdk"; | 7 | import { ConnectionId, ConnectionInUseError, type ScopeId } from "@executor-js/sdk"; |
| 8 | import { toast } from "sonner"; | 8 | import { toast } from "sonner"; |
| 9 | 9 | ||
| 10 | import { connectionUsagesAtom, removeConnection } from "../api/atoms"; | 10 | import { |
| 11 | import { useConnectionsWithPendingRemovals, usePendingConnectionRemovals } from "../api/optimistic"; | 11 | connectionUsagesAtom, |
| 12 | connectionsOptimisticAtom, | ||
| 13 | removeConnectionOptimistic, | ||
| 14 | } from "../api/atoms"; | ||
| 12 | import { connectionWriteKeys } from "../api/reactivity-keys"; | 15 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 13 | import { useScope, useScopeStack } from "../hooks/use-scope"; | 16 | import { useScope, useScopeStack } from "../hooks/use-scope"; |
| 14 | import { Badge } from "../components/badge"; | 17 | import { Badge } from "../components/badge"; |
| @@ -62,13 +65,8 @@ const connectionScopeLabel = ( · 47 unmodified lines | |||
| 62 | // don't get a stray "Used by 0" line before any source binds to them. | 65 | // don't get a stray "Used by 0" line before any source binds to them. |
| 63 | // --------------------------------------------------------------------------- | 66 | // --------------------------------------------------------------------------- |
| 64 | 67 | ||
| 65 | function ConnectionUsageFooter(props: { | 68 | function ConnectionUsageFooter(props: { scopeId: ScopeId; connectionId: ConnectionId }) { |
| 66 | scopeId: ScopeId; | 69 | const usages = useAtomValue(connectionUsagesAtom(props.scopeId, props.connectionId)); |
| 67 | connectionId: ConnectionId; | ||
| 68 | }) { | ||
| 69 | const usages = useAtomValue( | ||
| 70 | connectionUsagesAtom(props.scopeId, props.connectionId), | ||
| 71 | ); | ||
| 72 | return AsyncResult.match(usages, { | 70 | return AsyncResult.match(usages, { |
| 73 | onInitial: () => null, | 71 | onInitial: () => null, |
| 74 | onFailure: () => null, | 72 | onFailure: () => null, |
| @@ -164,18 +162,15 @@ function ConnectionRow(props: { · 89 unmodified lines | |||
| 164 | export function ConnectionsPage() { | 162 | export function ConnectionsPage() { |
| 165 | const scopeId = useScope(); | 163 | const scopeId = useScope(); |
| 166 | const scopeStack = useScopeStack(); | 164 | const scopeStack = useScopeStack(); |
| 167 | const connections = useConnectionsWithPendingRemovals(scopeId); | 165 | const connections = useAtomValue(connectionsOptimisticAtom(scopeId)); |
| 168 | const { beginRemove } = usePendingConnectionRemovals(); | 166 | const doRemove = useAtomSet(removeConnectionOptimistic(scopeId), { mode: "promiseExit" }); |
| 169 | const doRemove = useAtomSet(removeConnection, { mode: "promiseExit" }); | ||
| 170 | 167 | ||
| 171 | const handleRemove = async (connectionId: string) => { | 168 | const handleRemove = async (connectionId: string) => { |
| 172 | const pending = beginRemove(connectionId); | ||
| 173 | const exit = await doRemove({ | 169 | const exit = await doRemove({ |
| 174 | params: { scopeId, connectionId: ConnectionId.make(connectionId) }, | 170 | params: { scopeId, connectionId: ConnectionId.make(connectionId) }, |
| 175 | reactivityKeys: connectionWriteKeys, | 171 | reactivityKeys: connectionWriteKeys, |
| 176 | }); | 172 | }); |
| 177 | if (Exit.isFailure(exit)) { | 173 | if (Exit.isFailure(exit)) { |
| 178 | pending.undo(); | ||
| 179 | const error = Exit.findErrorOption(exit); | 174 | const error = Exit.findErrorOption(exit); |
| 180 | if (Option.isSome(error) && isConnectionInUseError(error.value)) { | 175 | if (Option.isSome(error) && isConnectionInUseError(error.value)) { |
| 181 | const count = error.value.usageCount; | 176 | const count = error.value.usageCount; |
| @@ -1,15 +1,15 @@ | |||
| 1 | import { Suspense, useEffect, useMemo, useState } from "react"; | 1 | import { Suspense, useEffect, useMemo, useState } from "react"; |
| 2 | import { useNavigate } from "@tanstack/react-router"; | 2 | import { useNavigate } from "@tanstack/react-router"; |
| 3 | import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react"; | 3 | import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 5 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 5 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 6 | import { effectivePolicyFromSorted } from "@executor-js/sdk"; | 6 | import { effectivePolicyFromSorted } from "@executor-js/sdk"; |
| 7 | import { | 7 | import { |
| 8 | policiesOptimisticAtom, | 8 | policiesOptimisticAtom, |
| 9 | sourceToolsAtom, | 9 | sourceToolsAtom, |
| 10 | sourcesAtom, | 10 | sourcesOptimisticAtom, |
| 11 | sourceAtom, | 11 | sourceAtom, |
| 12 | removeSource, | 12 | removeSourceOptimistic, |
| 13 | refreshSource, | 13 | refreshSource, |
| 14 | } from "../api/atoms"; | 14 | } from "../api/atoms"; |
| 15 | import { sourceWriteKeys } from "../api/reactivity-keys"; | 15 | import { sourceWriteKeys } from "../api/reactivity-keys"; |
| @@ -30,9 +30,9 @@ export function SourceDetailPage(props: { namespace: string }) { · 14 unmodified lines | |||
| 30 | const source = useAtomValue(sourceAtom(namespace, scopeId)); | 30 | const source = useAtomValue(sourceAtom(namespace, scopeId)); |
| 31 | const tools = useAtomValue(sourceToolsAtom(namespace, scopeId)); | 31 | const tools = useAtomValue(sourceToolsAtom(namespace, scopeId)); |
| 32 | const policies = useAtomValue(policiesOptimisticAtom(scopeId)); | 32 | const policies = useAtomValue(policiesOptimisticAtom(scopeId)); |
| 33 | const refreshSources = useAtomRefresh(sourcesAtom(scopeId)); | 33 | const refreshSources = useAtomRefresh(sourcesOptimisticAtom(scopeId)); |
| 34 | const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId)); | 34 | const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId)); |
| 35 | const doRemove = useAtomSet(removeSource, { mode: "promiseExit" }); | 35 | const doRemove = useAtomSet(removeSourceOptimistic(scopeId), { mode: "promiseExit" }); |
| 36 | const doRefresh = useAtomSet(refreshSource, { mode: "promiseExit" }); | 36 | const doRefresh = useAtomSet(refreshSource, { mode: "promiseExit" }); |
| 37 | const policyActions = usePolicyActions(scopeId); | 37 | const policyActions = usePolicyActions(scopeId); |
| 38 | const navigate = useNavigate(); | 38 | const navigate = useNavigate(); |
| @@ -1,17 +1,12 @@ | |||
| 1 | import { Suspense, useCallback, useMemo, useState } from "react"; | 1 | import { Suspense, useCallback, useMemo, useState } from "react"; |
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { useAtomSet } from "@effect/atom-react"; | 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | import * as Exit from "effect/Exit"; | 5 | import * as Exit from "effect/Exit"; |
| 6 | import { PlusIcon } from "lucide-react"; | 6 | import { PlusIcon } from "lucide-react"; |
| 7 | import type { SourceDetectionResult } from "@executor-js/sdk"; | 7 | import type { SourceDetectionResult } from "@executor-js/sdk"; |
| 8 | import { | 8 | import { useSourcePlugins, type SourcePlugin, type SourcePreset } from "@executor-js/sdk/client"; |
| 9 | useSourcePlugins, | 9 | import { detectSource, sourcesOptimisticAtom } from "../api/atoms"; |
| 10 | type SourcePlugin, | ||
| 11 | type SourcePreset, | ||
| 12 | } from "@executor-js/sdk/client"; | ||
| 13 | import { detectSource } from "../api/atoms"; | ||
| 14 | import { useSourcesWithPending } from "../api/optimistic"; | ||
| 15 | import { useScope } from "../hooks/use-scope"; | 10 | import { useScope } from "../hooks/use-scope"; |
| 16 | import { McpInstallCard } from "../components/mcp-install-card"; | 11 | import { McpInstallCard } from "../components/mcp-install-card"; |
| 17 | import { Button } from "../components/button"; | 12 | import { Button } from "../components/button"; |
| @@ -61,7 +56,7 @@ const bestDetection = ( · 43 unmodified lines | |||
| 61 | 56 | ||
| 62 | export function SourcesPage() { | 57 | export function SourcesPage() { |
| 63 | const scopeId = useScope(); | 58 | const scopeId = useScope(); |
| 64 | const sources = useSourcesWithPending(scopeId); | 59 | const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); |
| 65 | const [connectOpen, setConnectOpen] = useState(false); | 60 | const [connectOpen, setConnectOpen] = useState(false); |
| 66 | 61 | ||
| 67 | return ( | 62 | return ( |
| @@ -230,10 +225,7 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 162 unmodified lines | |||
| 230 | className="flex-1" | 225 | className="flex-1" |
| 231 | /> | 226 | /> |
| 232 | {isUrl && ( | 227 | {isUrl && ( |
| 233 | <Button | 228 | <Button onClick={() => void handleDetect()} disabled={detecting || !query.trim()}> |
| 234 | onClick={() => void handleDetect()} | ||
| 235 | disabled={detecting || !query.trim()} | ||
| 236 | > | ||
| 237 | {detecting ? "Detecting..." : "Detect"} | 229 | {detecting ? "Detecting..." : "Detect"} |
| 238 | </Button> | 230 | </Button> |
| 239 | )} | 231 | )} |
| @@ -258,11 +250,7 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 18 unmodified lines | |||
| 258 | </div> | 250 | </div> |
| 259 | </div> | 251 | </div> |
| 260 | 252 | ||
| 261 | <PresetGrid | 253 | <PresetGrid plugins={sourcePlugins} onPick={closeAndReset} searchQuery={presetSearch} /> |
| 262 | plugins={sourcePlugins} | ||
| 263 | onPick={closeAndReset} | ||
| 264 | searchQuery={presetSearch} | ||
| 265 | /> | ||
| 266 | </div> | 254 | </div> |
| 267 | </DialogContent> | 255 | </DialogContent> |
| 268 | </Dialog> | 256 | </Dialog> |
| @@ -326,8 +314,7 @@ function PresetGrid(props: { · 57 unmodified lines | |||
| 326 | const q = (props.searchQuery ?? "").trim().toLowerCase(); | 314 | const q = (props.searchQuery ?? "").trim().toLowerCase(); |
| 327 | if (q.length === 0) return allPresets; | 315 | if (q.length === 0) return allPresets; |
| 328 | return allPresets.filter(({ preset, pluginLabel }) => { | 316 | return allPresets.filter(({ preset, pluginLabel }) => { |
| 329 | const corpus = | 317 | const corpus = `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase(); |
| 330 | `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase(); | ||
| 331 | return corpus.includes(q); | 318 | return corpus.includes(q); |
| 332 | }); | 319 | }); |
| 333 | }, [allPresets, props.searchQuery]); | 320 | }, [allPresets, props.searchQuery]); |
| @@ -61,17 +61,13 @@ paths = [ · 60 unmodified lines | |||
| 61 | [[skills]] | 61 | [[skills]] |
| 62 | name = "wrdn-gha-workflows" | 62 | name = "wrdn-gha-workflows" |
| 63 | remote = "getsentry/warden-skills" | 63 | remote = "getsentry/warden-skills" |
| 64 | paths = [ | 64 | paths = [".github/workflows/**/*.yml", ".github/workflows/**/*.yaml"] |
| 65 | ".github/workflows/**/*.yml", | ||
| 66 | ".github/workflows/**/*.yaml", | ||
| 67 | ] | ||
| 68 | 65 | ||
| 69 | # Local skill — no `remote =`, resolved from `.agents/skills/<name>/SKILL.md`. | 66 | # Local skill — no `remote =`, resolved from `.agents/skills/<name>/SKILL.md`. |
| 70 | [[skills]] | 67 | [[skills]] |
| 71 | name = "wrdn-effect-atom-optimistic" | 68 | name = "wrdn-effect-atom-optimistic" |
| 72 | paths = [ | 69 | paths = [ |
| 73 | "packages/react/src/api/atoms.tsx", | 70 | "packages/react/src/api/atoms.tsx", |
| 74 | "packages/react/src/api/optimistic.tsx", | ||
| 75 | "packages/react/src/pages/**/*.tsx", | 71 | "packages/react/src/pages/**/*.tsx", |
| 76 | "packages/react/src/components/**/*.tsx", | 72 | "packages/react/src/components/**/*.tsx", |
| 77 | ] | 73 | ] |