From 902cc524c928044087496816250427d119a64147 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 20:16:26 -0500 Subject: [PATCH 01/15] chore(ci): align workflows with Depot runners --- .github/actionlint.yaml | 4 ++++ .github/workflows/cd-release.yml | 6 +++--- .github/workflows/ci-tests.yml | 6 +++--- .github/workflows/e2e-api.yml | 8 ++++---- .github/workflows/e2e-web.yml | 10 +++++----- .github/workflows/opencode-excalidraw-build.yml | 6 +++--- .github/workflows/opencode-excalidraw-bundle.yml | 6 +++--- .github/workflows/opencode-excalidraw-pr.yml | 4 ++-- .github/workflows/opencode-excalidraw-publish.yml | 2 +- .github/workflows/opencode-excalidraw-release.yml | 6 +++--- 10 files changed, 31 insertions(+), 27 deletions(-) create mode 100644 .github/actionlint.yaml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..c3c6783 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,4 @@ +self-hosted-runner: + labels: + - depot-ubuntu-24.04 + - depot-ubuntu-24.04-4 diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml index 455ebd2..a8a5896 100644 --- a/.github/workflows/cd-release.yml +++ b/.github/workflows/cd-release.yml @@ -18,14 +18,14 @@ concurrency: jobs: release: - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest # Skip if commit message contains [skip-release] or is a version bump commit if: | !contains(github.event.head_commit.message, '[skip-release]') && !contains(github.event.head_commit.message, 'chore: bump version') steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 @@ -191,7 +191,7 @@ jobs: exit 0 fi - if printf '%s' "$MERGE_OUTPUT" | grep -q "in clean status"; then + if printf '%s' "$MERGE_OUTPUT" | grep -Eq "in clean status|Auto merge is not allowed"; then echo "Auto-merge not applicable, merging immediately." gh pr merge "$PR_URL" \ --repo "$GITHUB_REPOSITORY" \ diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 70b3d33..1a7f275 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -33,7 +33,7 @@ jobs: (github.event_name != 'push' || ( !contains(github.event.head_commit.message, 'chore: bump version') )) - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: depot-ubuntu-24.04-4 env: AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} @@ -42,7 +42,7 @@ jobs: BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -86,7 +86,7 @@ jobs: - name: Upload test artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: ${{ env.BACKEND_ARTIFACT_NAME }} path: packages/backend/test-results diff --git a/.github/workflows/e2e-api.yml b/.github/workflows/e2e-api.yml index 559b68c..a5abcad 100644 --- a/.github/workflows/e2e-api.yml +++ b/.github/workflows/e2e-api.yml @@ -18,12 +18,12 @@ jobs: (startsWith(github.event.deployment_status.environment_url, 'https://') || startsWith(github.event.deployment_status.environment_url, 'http://')) && (github.event.deployment.environment == 'Preview' || github.event.deployment.environment == 'preview') - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: depot-ubuntu-24.04 timeout-minutes: 20 steps: - name: Check for doc-only changes id: doc_check - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const { owner, repo } = context.repo; @@ -84,7 +84,7 @@ jobs: - name: Checkout if: steps.doc_check.outputs.run_tests == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Bun if: steps.doc_check.outputs.run_tests == 'true' @@ -209,7 +209,7 @@ jobs: - name: Upload API test artifacts if: always() && steps.doc_check.outputs.run_tests == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: api-test-results-${{ github.run_id }} path: tests/api/results diff --git a/.github/workflows/e2e-web.yml b/.github/workflows/e2e-web.yml index cc05ebe..bc2b4b0 100644 --- a/.github/workflows/e2e-web.yml +++ b/.github/workflows/e2e-web.yml @@ -18,12 +18,12 @@ jobs: (startsWith(github.event.deployment_status.environment_url, 'https://') || startsWith(github.event.deployment_status.environment_url, 'http://')) && (github.event.deployment.environment == 'Preview' || github.event.deployment.environment == 'preview') - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: depot-ubuntu-24.04 timeout-minutes: 35 steps: - name: Check for doc-only changes id: doc_check - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const { owner, repo } = context.repo; @@ -84,7 +84,7 @@ jobs: - name: Checkout if: steps.doc_check.outputs.run_tests == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Bun if: steps.doc_check.outputs.run_tests == 'true' @@ -235,7 +235,7 @@ jobs: - name: Comment Browserbase replay links on PR if: always() && steps.doc_check.outputs.run_tests == 'true' - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const fs = require("fs"); @@ -361,7 +361,7 @@ jobs: - name: Upload E2E artifacts if: always() && steps.doc_check.outputs.run_tests == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: e2e-artifacts-${{ github.run_id }} path: tests/e2e/artifacts diff --git a/.github/workflows/opencode-excalidraw-build.yml b/.github/workflows/opencode-excalidraw-build.yml index 0483fef..2ebb334 100644 --- a/.github/workflows/opencode-excalidraw-build.yml +++ b/.github/workflows/opencode-excalidraw-build.yml @@ -14,10 +14,10 @@ permissions: jobs: build: - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: depot-ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Tooling uses: jdx/mise-action@d6e32c1796099e0f1f3ac741c220a8b7eae9e5dd @@ -34,7 +34,7 @@ jobs: mise run -C packages/opencode-excalidraw build - name: Upload dist artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: opencode-excalidraw-dist if-no-files-found: error diff --git a/.github/workflows/opencode-excalidraw-bundle.yml b/.github/workflows/opencode-excalidraw-bundle.yml index 25f3811..46a3490 100644 --- a/.github/workflows/opencode-excalidraw-bundle.yml +++ b/.github/workflows/opencode-excalidraw-bundle.yml @@ -8,10 +8,10 @@ permissions: jobs: bundle: - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: depot-ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Tooling uses: jdx/mise-action@d6e32c1796099e0f1f3ac741c220a8b7eae9e5dd @@ -38,7 +38,7 @@ jobs: tar -czf opencode-excalidraw-bundle.tgz -C bundle . - name: Upload bundle - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: opencode-excalidraw-bundle if-no-files-found: error diff --git a/.github/workflows/opencode-excalidraw-pr.yml b/.github/workflows/opencode-excalidraw-pr.yml index 467ab57..6c75c47 100644 --- a/.github/workflows/opencode-excalidraw-pr.yml +++ b/.github/workflows/opencode-excalidraw-pr.yml @@ -17,12 +17,12 @@ permissions: jobs: opencode-excalidraw: - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: depot-ubuntu-24.04 env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha || github.ref }} diff --git a/.github/workflows/opencode-excalidraw-publish.yml b/.github/workflows/opencode-excalidraw-publish.yml index ab5fa4a..c4e7a89 100644 --- a/.github/workflows/opencode-excalidraw-publish.yml +++ b/.github/workflows/opencode-excalidraw-publish.yml @@ -22,7 +22,7 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/opencode-excalidraw-release.yml b/.github/workflows/opencode-excalidraw-release.yml index 5a7bd8f..e66c774 100644 --- a/.github/workflows/opencode-excalidraw-release.yml +++ b/.github/workflows/opencode-excalidraw-release.yml @@ -19,11 +19,11 @@ permissions: jobs: process: - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest outputs: releases_created: ${{ steps.release_please.outputs.releases_created }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -37,7 +37,7 @@ jobs: dispatch-publish: needs: process - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest if: needs.process.result == 'success' steps: - name: Dispatch publish for releases From a857e93a67e66f15d9aab926e969f2fbfd66b25f Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 20:58:44 -0500 Subject: [PATCH 02/15] fix(vercel): skip version-only package deploys --- scripts/vercel-ignore-web-build.sh | 53 +++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/scripts/vercel-ignore-web-build.sh b/scripts/vercel-ignore-web-build.sh index 5ab1632..49b56f1 100755 --- a/scripts/vercel-ignore-web-build.sh +++ b/scripts/vercel-ignore-web-build.sh @@ -3,6 +3,7 @@ set -euo pipefail BEFORE_SHA="${VERCEL_GIT_PREVIOUS_SHA:-}" AFTER_SHA="${VERCEL_GIT_COMMIT_SHA:-}" +export BEFORE_SHA AFTER_SHA if [ -z "${BEFORE_SHA}" ] || [ -z "${AFTER_SHA}" ]; then echo "Missing Vercel commit SHAs; continue with build." @@ -19,10 +20,60 @@ if [ -z "${CHANGED_FILES}" ]; then exit 0 fi +is_version_only_package_json_change() { + node <<'NODE' +const { execFileSync } = require("node:child_process"); + +const beforeSha = process.env.BEFORE_SHA; +const afterSha = process.env.AFTER_SHA; + +function readPackageJson(sha) { + return JSON.parse(execFileSync("git", ["show", `${sha}:package.json`], { + encoding: "utf8", + })); +} + +function withoutVersion(packageJson) { + const copy = { ...packageJson }; + delete copy.version; + return copy; +} + +try { + const beforePackageJson = readPackageJson(beforeSha); + const afterPackageJson = readPackageJson(afterSha); + + if (beforePackageJson.version === afterPackageJson.version) { + process.exit(1); + } + + const beforeWithoutVersion = withoutVersion(beforePackageJson); + const afterWithoutVersion = withoutVersion(afterPackageJson); + + process.exit( + JSON.stringify(beforeWithoutVersion) === JSON.stringify(afterWithoutVersion) + ? 0 + : 1 + ); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} +NODE +} + ONLY_NON_WEB_CHANGES="true" while IFS= read -r file; do [ -z "${file}" ] && continue case "${file}" in + package.json) + if is_version_only_package_json_change; then + true + else + ONLY_NON_WEB_CHANGES="false" + break + fi + ;; README.md|AGENTS.md|bun.lock) ;; docs/*|.codex/*) ;; packages/opencode-excalidraw/*) ;; @@ -35,7 +86,7 @@ while IFS= read -r file; do done <<< "${CHANGED_FILES}" if [ "${ONLY_NON_WEB_CHANGES}" = "true" ]; then - echo "Only OpenCode plugin/non-web paths changed; skip web build." + echo "Only non-web paths changed; skip web build." exit 0 fi From 65f62f8dfa00452f0e17b5a383719c3c396d8c53 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 21:18:25 -0500 Subject: [PATCH 03/15] chore(ci): restrict Depot PR jobs to trusted branches --- .github/workflows/ci-tests.yml | 6 +++++ .github/workflows/e2e-api.yml | 24 +++++++++++++++----- .github/workflows/e2e-web.yml | 24 +++++++++++++++----- .github/workflows/opencode-excalidraw-pr.yml | 9 ++++++++ 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 1a7f275..b67601a 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -27,6 +27,12 @@ jobs: convex-tests: if: | (github.event_name != 'pull_request' || ( + github.event.pull_request.head.repo.full_name == github.repository && + ( + github.event.pull_request.author_association == 'OWNER' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.author_association == 'COLLABORATOR' + ) && !startsWith(github.event.pull_request.title, 'chore: bump version') && !startsWith(github.event.pull_request.head.ref, 'chore/bump-version') )) && diff --git a/.github/workflows/e2e-api.yml b/.github/workflows/e2e-api.yml index a5abcad..c2d36b5 100644 --- a/.github/workflows/e2e-api.yml +++ b/.github/workflows/e2e-api.yml @@ -21,7 +21,7 @@ jobs: runs-on: depot-ubuntu-24.04 timeout-minutes: 20 steps: - - name: Check for doc-only changes + - name: Check preview test policy id: doc_check uses: actions/github-script@v9 with: @@ -31,8 +31,8 @@ jobs: context.payload.deployment?.sha ?? context.payload.deployment_status?.deployment?.sha; if (!sha) { - core.warning("No deployment SHA found; running tests."); - core.setOutput("run_tests", "true"); + core.warning("No deployment SHA found; skipping E2E API tests."); + core.setOutput("run_tests", "false"); return; } let prs = []; @@ -46,16 +46,28 @@ jobs: prs = response.data ?? []; } catch (error) { core.warning(`Failed to resolve PR for ${sha}: ${error.message}`); - core.setOutput("run_tests", "true"); + core.setOutput("run_tests", "false"); return; } if (!prs.length) { - core.notice(`No PR found for ${sha}; running tests.`); - core.setOutput("run_tests", "true"); + core.notice(`No PR found for ${sha}; skipping E2E API tests.`); + core.setOutput("run_tests", "false"); return; } const pr = prs[0]; const prNumber = pr.number; + const sameRepo = pr.head?.repo?.full_name === `${owner}/${repo}`; + const trustedAssociation = ["OWNER", "MEMBER", "COLLABORATOR"].includes( + pr.author_association + ); + const isDependabot = pr.user?.login === "dependabot[bot]"; + if (!sameRepo || !trustedAssociation || isDependabot) { + core.notice( + `PR #${prNumber} is not trusted for preview E2E; skipping API tests.` + ); + core.setOutput("run_tests", "false"); + return; + } const isBumpPr = pr.title?.startsWith("chore: bump version") || pr.head?.ref?.startsWith("chore/bump-version"); diff --git a/.github/workflows/e2e-web.yml b/.github/workflows/e2e-web.yml index bc2b4b0..ff431ba 100644 --- a/.github/workflows/e2e-web.yml +++ b/.github/workflows/e2e-web.yml @@ -21,7 +21,7 @@ jobs: runs-on: depot-ubuntu-24.04 timeout-minutes: 35 steps: - - name: Check for doc-only changes + - name: Check preview test policy id: doc_check uses: actions/github-script@v9 with: @@ -31,8 +31,8 @@ jobs: context.payload.deployment?.sha ?? context.payload.deployment_status?.deployment?.sha; if (!sha) { - core.warning("No deployment SHA found; running tests."); - core.setOutput("run_tests", "true"); + core.warning("No deployment SHA found; skipping E2E web tests."); + core.setOutput("run_tests", "false"); return; } let prs = []; @@ -46,16 +46,28 @@ jobs: prs = response.data ?? []; } catch (error) { core.warning(`Failed to resolve PR for ${sha}: ${error.message}`); - core.setOutput("run_tests", "true"); + core.setOutput("run_tests", "false"); return; } if (!prs.length) { - core.notice(`No PR found for ${sha}; running tests.`); - core.setOutput("run_tests", "true"); + core.notice(`No PR found for ${sha}; skipping E2E web tests.`); + core.setOutput("run_tests", "false"); return; } const pr = prs[0]; const prNumber = pr.number; + const sameRepo = pr.head?.repo?.full_name === `${owner}/${repo}`; + const trustedAssociation = ["OWNER", "MEMBER", "COLLABORATOR"].includes( + pr.author_association + ); + const isDependabot = pr.user?.login === "dependabot[bot]"; + if (!sameRepo || !trustedAssociation || isDependabot) { + core.notice( + `PR #${prNumber} is not trusted for preview E2E; skipping web tests.` + ); + core.setOutput("run_tests", "false"); + return; + } const isBumpPr = pr.title?.startsWith("chore: bump version") || pr.head?.ref?.startsWith("chore/bump-version"); diff --git a/.github/workflows/opencode-excalidraw-pr.yml b/.github/workflows/opencode-excalidraw-pr.yml index 6c75c47..2d1ee6b 100644 --- a/.github/workflows/opencode-excalidraw-pr.yml +++ b/.github/workflows/opencode-excalidraw-pr.yml @@ -17,6 +17,15 @@ permissions: jobs: opencode-excalidraw: + if: | + github.event_name != 'pull_request' || ( + github.event.pull_request.head.repo.full_name == github.repository && + ( + github.event.pull_request.author_association == 'OWNER' || + github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.author_association == 'COLLABORATOR' + ) + ) runs-on: depot-ubuntu-24.04 env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" From 159aa6be63c0f7f6ec89324565688c46d3a7a599 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 22:27:11 -0500 Subject: [PATCH 04/15] fix(ci): address preview auth and policy feedback --- .github/workflows/e2e-api.yml | 20 ++++++++++---------- .github/workflows/e2e-web.yml | 20 ++++++++++---------- packages/backend/convex/auth.config.ts | 12 ++++++++++++ tests/e2e/src/runner/auth.ts | 14 +++++++++++++- 4 files changed, 45 insertions(+), 21 deletions(-) diff --git a/.github/workflows/e2e-api.yml b/.github/workflows/e2e-api.yml index c2d36b5..e680718 100644 --- a/.github/workflows/e2e-api.yml +++ b/.github/workflows/e2e-api.yml @@ -31,9 +31,9 @@ jobs: context.payload.deployment?.sha ?? context.payload.deployment_status?.deployment?.sha; if (!sha) { - core.warning("No deployment SHA found; skipping E2E API tests."); - core.setOutput("run_tests", "false"); - return; + throw new Error( + "No deployment SHA found; cannot evaluate E2E API policy." + ); } let prs = []; try { @@ -42,17 +42,17 @@ jobs: owner, repo, commit_sha: sha, - }); + }); prs = response.data ?? []; } catch (error) { - core.warning(`Failed to resolve PR for ${sha}: ${error.message}`); - core.setOutput("run_tests", "false"); - return; + throw new Error( + `Failed to resolve PR for ${sha}: ${error.message}` + ); } if (!prs.length) { - core.notice(`No PR found for ${sha}; skipping E2E API tests.`); - core.setOutput("run_tests", "false"); - return; + throw new Error( + `No PR found for ${sha}; cannot evaluate E2E API policy.` + ); } const pr = prs[0]; const prNumber = pr.number; diff --git a/.github/workflows/e2e-web.yml b/.github/workflows/e2e-web.yml index ff431ba..3e98a05 100644 --- a/.github/workflows/e2e-web.yml +++ b/.github/workflows/e2e-web.yml @@ -31,9 +31,9 @@ jobs: context.payload.deployment?.sha ?? context.payload.deployment_status?.deployment?.sha; if (!sha) { - core.warning("No deployment SHA found; skipping E2E web tests."); - core.setOutput("run_tests", "false"); - return; + throw new Error( + "No deployment SHA found; cannot evaluate E2E web policy." + ); } let prs = []; try { @@ -42,17 +42,17 @@ jobs: owner, repo, commit_sha: sha, - }); + }); prs = response.data ?? []; } catch (error) { - core.warning(`Failed to resolve PR for ${sha}: ${error.message}`); - core.setOutput("run_tests", "false"); - return; + throw new Error( + `Failed to resolve PR for ${sha}: ${error.message}` + ); } if (!prs.length) { - core.notice(`No PR found for ${sha}; skipping E2E web tests.`); - core.setOutput("run_tests", "false"); - return; + throw new Error( + `No PR found for ${sha}; cannot evaluate E2E web policy.` + ); } const pr = prs[0]; const prNumber = pr.number; diff --git a/packages/backend/convex/auth.config.ts b/packages/backend/convex/auth.config.ts index 45faa59..666254d 100644 --- a/packages/backend/convex/auth.config.ts +++ b/packages/backend/convex/auth.config.ts @@ -17,6 +17,12 @@ export default { applicationID: clientId, jwks, }, + { + type: "customJwt", + issuer: "https://api.workos.com", + algorithm: "RS256", + jwks, + }, { type: "customJwt", issuer: "https://api.workos.com/", @@ -24,6 +30,12 @@ export default { applicationID: clientId, jwks, }, + { + type: "customJwt", + issuer: "https://api.workos.com/", + algorithm: "RS256", + jwks, + }, { type: "customJwt", issuer: `https://api.workos.com/user_management/${clientId}`, diff --git a/tests/e2e/src/runner/auth.ts b/tests/e2e/src/runner/auth.ts index 6909801..cea7b1f 100644 --- a/tests/e2e/src/runner/auth.ts +++ b/tests/e2e/src/runner/auth.ts @@ -124,7 +124,16 @@ async function continueFromSignedInPage( page: AuthPage, baseUrl: string ): Promise { - const clicked = await clickIfVisible(page, 'a:has-text("Continue")'); + const hasCredentialsForm = + (await pageHasSelector(page, 'input[type="email"]')) || + (await pageHasSelector(page, 'input[type="password"]')); + if (hasCredentialsForm) { + return false; + } + + const clicked = + (await clickIfVisible(page, 'a:has-text("Continue")')) || + (await clickIfVisible(page, 'button:has-text("Continue")')); if (!clicked) { return false; } @@ -257,6 +266,9 @@ export async function ensureSignedInForDiagrams( const stillShowsSignInCta = await pageShowsSignInCallToAction(page); if (!page.url().includes("/diagrams") || stillShowsSignInCta) { await openHostedSignInIfNeeded(page); + if (await continueFromSignedInPage(page, baseUrl)) { + return; + } if (!(credentials.email && credentials.password)) { throw new Error( "Diagram Studio requires sign-in. Set SKETCHI_E2E_EMAIL and SKETCHI_E2E_PASSWORD for authenticated E2E." From cc9c8c8e1b9fd145a625d1cec416e6002b16f231 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 22:39:12 -0500 Subject: [PATCH 05/15] fix(ci): restore WorkOS audience validation --- .vercelignore | 1 - apps/web/vercel.json | 2 +- packages/backend/convex/auth.config.ts | 12 --- scripts/configure-workos-jwt-template.mjs | 105 ++++++++++++++++++++++ 4 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 scripts/configure-workos-jwt-template.mjs diff --git a/.vercelignore b/.vercelignore index b39c9b7..e6ebad0 100644 --- a/.vercelignore +++ b/.vercelignore @@ -1,4 +1,3 @@ -.git .github .memory .turbo diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 71c50ce..835430c 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -2,5 +2,5 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "bunVersion": "1.x", "ignoreCommand": "bash ../../scripts/vercel-ignore-web-build.sh", - "buildCommand": "cd ../../packages/backend && npx convex deploy --cmd 'cd ../../apps/web && bun run --bun turbo run build' --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL" + "buildCommand": "cd ../.. && bun scripts/configure-workos-jwt-template.mjs && cd packages/backend && npx convex deploy --cmd 'cd ../../apps/web && bun run --bun turbo run build' --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL" } diff --git a/packages/backend/convex/auth.config.ts b/packages/backend/convex/auth.config.ts index 666254d..45faa59 100644 --- a/packages/backend/convex/auth.config.ts +++ b/packages/backend/convex/auth.config.ts @@ -17,12 +17,6 @@ export default { applicationID: clientId, jwks, }, - { - type: "customJwt", - issuer: "https://api.workos.com", - algorithm: "RS256", - jwks, - }, { type: "customJwt", issuer: "https://api.workos.com/", @@ -30,12 +24,6 @@ export default { applicationID: clientId, jwks, }, - { - type: "customJwt", - issuer: "https://api.workos.com/", - algorithm: "RS256", - jwks, - }, { type: "customJwt", issuer: `https://api.workos.com/user_management/${clientId}`, diff --git a/scripts/configure-workos-jwt-template.mjs b/scripts/configure-workos-jwt-template.mjs new file mode 100644 index 0000000..c0e24b5 --- /dev/null +++ b/scripts/configure-workos-jwt-template.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +const WORKOS_API_BASE_URL = "https://api.workos.com"; +const JWT_TEMPLATE_PATH = "/user_management/jwt_template"; + +const apiKey = process.env.WORKOS_API_KEY?.trim(); +const clientId = process.env.WORKOS_CLIENT_ID?.trim(); +const dryRun = process.argv.includes("--dry-run"); + +if (!apiKey) { + throw new Error( + "WORKOS_API_KEY must be set to configure WorkOS JWT template" + ); +} + +if (!clientId) { + throw new Error( + "WORKOS_CLIENT_ID must be set to configure WorkOS JWT template" + ); +} + +async function requestWorkOs(path, options = {}) { + const response = await fetch(`${WORKOS_API_BASE_URL}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + ...options.headers, + }, + }); + + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + + if (!response.ok) { + const message = + payload && typeof payload === "object" && "message" in payload + ? payload.message + : text; + throw new Error( + `WorkOS ${options.method ?? "GET"} ${path} failed: ${message}` + ); + } + + return payload; +} + +async function readCurrentTemplate() { + try { + const payload = await requestWorkOs(JWT_TEMPLATE_PATH); + const content = + payload?.jwt_template && + typeof payload.jwt_template === "object" && + "content" in payload.jwt_template + ? payload.jwt_template.content + : undefined; + + if (!(typeof content === "string" && content.trim().length > 0)) { + return {}; + } + + const parsed = JSON.parse(content); + if (!(parsed && typeof parsed === "object" && !Array.isArray(parsed))) { + throw new Error( + "Existing WorkOS JWT template content is not a JSON object" + ); + } + return parsed; + } catch (error) { + if ( + error instanceof Error && + error.message.includes("WorkOS GET") && + error.message.includes("404") + ) { + return {}; + } + throw error; + } +} + +const currentTemplate = dryRun ? {} : await readCurrentTemplate(); +const nextTemplate = { + ...currentTemplate, + aud: clientId, +}; +const nextContent = JSON.stringify(nextTemplate); + +if (currentTemplate.aud === clientId) { + console.log(`WorkOS JWT template already has aud=${clientId}`); + process.exit(0); +} + +if (dryRun) { + console.log(nextContent); + process.exit(0); +} + +await requestWorkOs(JWT_TEMPLATE_PATH, { + method: "PUT", + body: JSON.stringify({ + content: nextContent, + }), +}); + +console.log(`Configured WorkOS JWT template aud=${clientId}`); From de0c8323e90db7165145e310d5b6e0c95a5f859d Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 22:40:18 -0500 Subject: [PATCH 06/15] fix(ci): handle missing WorkOS JWT template --- scripts/configure-workos-jwt-template.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/configure-workos-jwt-template.mjs b/scripts/configure-workos-jwt-template.mjs index c0e24b5..19b84ce 100644 --- a/scripts/configure-workos-jwt-template.mjs +++ b/scripts/configure-workos-jwt-template.mjs @@ -70,7 +70,8 @@ async function readCurrentTemplate() { if ( error instanceof Error && error.message.includes("WorkOS GET") && - error.message.includes("404") + (error.message.includes("404") || + error.message.includes("JWT template not found")) ) { return {}; } From 5ae209097b10295dc460452d17c0fe95b32a8778 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 22:49:56 -0500 Subject: [PATCH 07/15] fix(ci): sync WorkOS auth before preview deploy --- apps/web/vercel.json | 2 +- scripts/prepare-convex-workos-auth.sh | 18 ++++++++++++ tests/e2e/src/runner/auth.ts | 42 ++++++++++++++++++++------- 3 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 scripts/prepare-convex-workos-auth.sh diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 835430c..a3acb5f 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -2,5 +2,5 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "bunVersion": "1.x", "ignoreCommand": "bash ../../scripts/vercel-ignore-web-build.sh", - "buildCommand": "cd ../.. && bun scripts/configure-workos-jwt-template.mjs && cd packages/backend && npx convex deploy --cmd 'cd ../../apps/web && bun run --bun turbo run build' --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL" + "buildCommand": "cd ../.. && bash scripts/prepare-convex-workos-auth.sh && cd packages/backend && npx convex deploy --cmd 'cd ../../apps/web && bun run --bun turbo run build' --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL" } diff --git a/scripts/prepare-convex-workos-auth.sh b/scripts/prepare-convex-workos-auth.sh new file mode 100644 index 0000000..9756db6 --- /dev/null +++ b/scripts/prepare-convex-workos-auth.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -z "${WORKOS_CLIENT_ID:-}" ]; then + echo "WORKOS_CLIENT_ID must be set before Convex deploy." + exit 1 +fi + +if [ -z "${WORKOS_API_KEY:-}" ]; then + echo "WORKOS_API_KEY must be set before Convex deploy." + exit 1 +fi + +bun scripts/configure-workos-jwt-template.mjs + +cd packages/backend +npx convex env set WORKOS_CLIENT_ID "${WORKOS_CLIENT_ID}" +npx convex env set WORKOS_API_KEY "${WORKOS_API_KEY}" diff --git a/tests/e2e/src/runner/auth.ts b/tests/e2e/src/runner/auth.ts index cea7b1f..e093a3b 100644 --- a/tests/e2e/src/runner/auth.ts +++ b/tests/e2e/src/runner/auth.ts @@ -46,6 +46,18 @@ function getAuthCredentials(): return { email, password }; } +function requireAuthCredentials( + credentials: ReturnType +): asserts credentials is { email: string; password: string } { + if (credentials.email && credentials.password) { + return; + } + + throw new Error( + "Diagram Studio requires sign-in. Set SKETCHI_E2E_EMAIL and SKETCHI_E2E_PASSWORD for authenticated E2E." + ); +} + async function pageHasSelector(page: AuthPage, selector: string) { return await page.evaluate((querySelector) => { return Boolean(document.querySelector(querySelector)); @@ -225,6 +237,21 @@ async function submitPasswordStep( await clickIfVisible(page, 'button[type="submit"]'); } +async function continueOrRequireCredentialsForm( + page: AuthPage, + baseUrl: string +): Promise { + try { + await ensureCredentialsForm(page); + return false; + } catch (error) { + if (await continueFromSignedInPage(page, baseUrl)) { + return true; + } + throw error; + } +} + export async function ensureSignedInForDiagrams( page: AuthPage, baseUrl: string @@ -256,11 +283,8 @@ export async function ensureSignedInForDiagrams( ); const continued = clickedContinueToSignIn || (await continueFromSignedInPage(page, baseUrl)); - const hasCredentials = Boolean(credentials.email && credentials.password); - if (!(continued || hasCredentials)) { - throw new Error( - "Diagram Studio requires sign-in. Set SKETCHI_E2E_EMAIL and SKETCHI_E2E_PASSWORD for authenticated E2E." - ); + if (!continued) { + requireAuthCredentials(credentials); } const stillShowsSignInCta = await pageShowsSignInCallToAction(page); @@ -269,12 +293,10 @@ export async function ensureSignedInForDiagrams( if (await continueFromSignedInPage(page, baseUrl)) { return; } - if (!(credentials.email && credentials.password)) { - throw new Error( - "Diagram Studio requires sign-in. Set SKETCHI_E2E_EMAIL and SKETCHI_E2E_PASSWORD for authenticated E2E." - ); + requireAuthCredentials(credentials); + if (await continueOrRequireCredentialsForm(page, baseUrl)) { + return; } - await ensureCredentialsForm(page); await submitEmailStep(page, credentials.email); await submitPasswordStep(page, credentials.password); await waitForDiagramsReturn(page, "auth-return-diagrams", baseUrl); From f1f64b225746c15f61e77f151b294a4ca34a42f1 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 22:52:42 -0500 Subject: [PATCH 08/15] fix(e2e): preserve final auth guard --- tests/e2e/src/runner/auth.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/e2e/src/runner/auth.ts b/tests/e2e/src/runner/auth.ts index e093a3b..d5f9e68 100644 --- a/tests/e2e/src/runner/auth.ts +++ b/tests/e2e/src/runner/auth.ts @@ -290,16 +290,15 @@ export async function ensureSignedInForDiagrams( const stillShowsSignInCta = await pageShowsSignInCallToAction(page); if (!page.url().includes("/diagrams") || stillShowsSignInCta) { await openHostedSignInIfNeeded(page); - if (await continueFromSignedInPage(page, baseUrl)) { - return; - } - requireAuthCredentials(credentials); - if (await continueOrRequireCredentialsForm(page, baseUrl)) { - return; + const usedExistingSession = + (await continueFromSignedInPage(page, baseUrl)) || + (await continueOrRequireCredentialsForm(page, baseUrl)); + if (!usedExistingSession) { + requireAuthCredentials(credentials); + await submitEmailStep(page, credentials.email); + await submitPasswordStep(page, credentials.password); + await waitForDiagramsReturn(page, "auth-return-diagrams", baseUrl); } - await submitEmailStep(page, credentials.email); - await submitPasswordStep(page, credentials.password); - await waitForDiagramsReturn(page, "auth-return-diagrams", baseUrl); } const finalSignInCta = await pageShowsSignInCallToAction(page); From 22508fbbe6d586b2ca5237ae6be38a7e51f23b32 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 22:54:40 -0500 Subject: [PATCH 09/15] fix(ci): avoid Convex env writes from Vercel deploy --- scripts/prepare-convex-workos-auth.sh | 4 ---- 1 file changed, 4 deletions(-) diff --git a/scripts/prepare-convex-workos-auth.sh b/scripts/prepare-convex-workos-auth.sh index 9756db6..f18ffc0 100644 --- a/scripts/prepare-convex-workos-auth.sh +++ b/scripts/prepare-convex-workos-auth.sh @@ -12,7 +12,3 @@ if [ -z "${WORKOS_API_KEY:-}" ]; then fi bun scripts/configure-workos-jwt-template.mjs - -cd packages/backend -npx convex env set WORKOS_CLIENT_ID "${WORKOS_CLIENT_ID}" -npx convex env set WORKOS_API_KEY "${WORKOS_API_KEY}" From e51731afa2e585ac517998c53bf5241e7810b747 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 23:09:00 -0500 Subject: [PATCH 10/15] fix(e2e): harden WorkOS auth diagnostics --- .../backend/scripts/mint-device-token.mjs | 36 +++++++++++++++++++ tests/e2e/src/runner/auth.ts | 32 ++++++++++++----- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/packages/backend/scripts/mint-device-token.mjs b/packages/backend/scripts/mint-device-token.mjs index 6a5b1f5..354f570 100644 --- a/packages/backend/scripts/mint-device-token.mjs +++ b/packages/backend/scripts/mint-device-token.mjs @@ -14,6 +14,41 @@ function resolveUrl(baseUrl, path) { return new URL(path, baseUrl).toString(); } +function decodeJwtClaims(token) { + const [, payload] = token.split("."); + if (!payload) { + return null; + } + + try { + const normalized = payload.replace(/-/g, "+").replace(/_/g, "/"); + return JSON.parse(Buffer.from(normalized, "base64").toString("utf8")); + } catch { + return null; + } +} + +function logAccessTokenClaims(token) { + const claims = decodeJwtClaims(token); + if (!claims || typeof claims !== "object") { + console.error("[device-flow] access token claims unavailable"); + return; + } + + const safeClaims = { + iss: typeof claims.iss === "string" ? claims.iss : undefined, + aud: + typeof claims.aud === "string" || Array.isArray(claims.aud) + ? claims.aud + : undefined, + client_id: + typeof claims.client_id === "string" ? claims.client_id : undefined, + }; + console.error( + `[device-flow] access token claims ${JSON.stringify(safeClaims)}` + ); +} + async function wait(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } @@ -343,6 +378,7 @@ async function main() { started.intervalSeconds, started.expiresInSeconds ); + logAccessTokenClaims(accessToken); process.stdout.write(accessToken); } diff --git a/tests/e2e/src/runner/auth.ts b/tests/e2e/src/runner/auth.ts index d5f9e68..f7a6974 100644 --- a/tests/e2e/src/runner/auth.ts +++ b/tests/e2e/src/runner/auth.ts @@ -25,6 +25,20 @@ interface AuthPage { url: () => string; } +const emailInputSelector = [ + 'input[type="email"]', + 'input[name*="email" i]', + 'input[id*="email" i]', + 'input[autocomplete="email"]', +].join(","); + +const passwordInputSelector = [ + 'input[type="password"]', + 'input[name*="password" i]', + 'input[id*="password" i]', + 'input[autocomplete="current-password"]', +].join(","); + function getCredential(name: string): string | null { const value = process.env[name]?.trim(); return value && value.length > 0 ? value : null; @@ -137,8 +151,8 @@ async function continueFromSignedInPage( baseUrl: string ): Promise { const hasCredentialsForm = - (await pageHasSelector(page, 'input[type="email"]')) || - (await pageHasSelector(page, 'input[type="password"]')); + (await pageHasSelector(page, emailInputSelector)) || + (await pageHasSelector(page, passwordInputSelector)); if (hasCredentialsForm) { return false; } @@ -179,11 +193,11 @@ async function openHostedSignInIfNeeded(page: AuthPage): Promise { async function ensureCredentialsForm(page: AuthPage): Promise { const reachedCredentialsForm = await waitForCondition( async () => { - const hasPassword = await pageHasSelector(page, 'input[type="password"]'); + const hasPassword = await pageHasSelector(page, passwordInputSelector); if (hasPassword) { return true; } - return await pageHasSelector(page, 'input[type="email"]'); + return await pageHasSelector(page, emailInputSelector); }, { timeoutMs: 30_000, label: "workos-credentials-form" } ); @@ -195,13 +209,13 @@ async function ensureCredentialsForm(page: AuthPage): Promise { async function submitEmailStep(page: AuthPage, email: string): Promise { const hasPasswordFieldFirst = await pageHasSelector( page, - 'input[type="password"]' + passwordInputSelector ); if (hasPasswordFieldFirst) { return; } - await page.locator('input[type="email"]').first().fill(email); + await page.locator(emailInputSelector).first().fill(email); if (page.keyboard) { await page.keyboard.press("Enter"); return; @@ -214,13 +228,13 @@ async function submitPasswordStep( password: string ): Promise { let hasPasswordField = await waitForCondition( - () => pageHasSelector(page, 'input[type="password"]'), + () => pageHasSelector(page, passwordInputSelector), { timeoutMs: 12_000, label: "workos-password-input-enter" } ); if (!hasPasswordField) { await clickIfVisible(page, 'button[type="submit"]'); hasPasswordField = await waitForCondition( - () => pageHasSelector(page, 'input[type="password"]'), + () => pageHasSelector(page, passwordInputSelector), { timeoutMs: 12_000, label: "workos-password-input-submit" } ); } @@ -229,7 +243,7 @@ async function submitPasswordStep( throw new Error("WorkOS password input did not appear."); } - await page.locator('input[type="password"]').first().fill(password); + await page.locator(passwordInputSelector).first().fill(password); if (page.keyboard) { await page.keyboard.press("Enter"); return; From bd38bc81df502cd0e373b0b488f8d794447c72a1 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 23:18:30 -0500 Subject: [PATCH 11/15] fix(e2e): align preview WorkOS auth providers --- packages/backend/convex/auth.config.ts | 74 ++++++++++++++++---------- tests/e2e/src/runner/auth.ts | 19 +++++-- 2 files changed, 60 insertions(+), 33 deletions(-) diff --git a/packages/backend/convex/auth.config.ts b/packages/backend/convex/auth.config.ts index 45faa59..0c985d3 100644 --- a/packages/backend/convex/auth.config.ts +++ b/packages/backend/convex/auth.config.ts @@ -6,35 +6,51 @@ if (!clientId) { throw new Error("WORKOS_CLIENT_ID must be set for WorkOS AuthKit auth"); } -const jwks = `https://api.workos.com/sso/jwks/${clientId}`; +const previewWorkOsEnvironmentId = "environment_01KFPXKKS4QA3NP3VT0Q52WXB1"; +const previewWorkOsClientId = "client_01KFPXKM905BYDQY5Q7BFJN409"; + +const clientIds = Array.from( + new Set( + [ + clientId, + process.env.WORKOS_ENVIRONMENT_ID === previewWorkOsEnvironmentId || + process.env.VERCEL_ENV === "preview" + ? previewWorkOsClientId + : null, + ].filter((value): value is string => Boolean(value)) + ) +); export default { - providers: [ - { - type: "customJwt", - issuer: "https://api.workos.com", - algorithm: "RS256", - applicationID: clientId, - jwks, - }, - { - type: "customJwt", - issuer: "https://api.workos.com/", - algorithm: "RS256", - applicationID: clientId, - jwks, - }, - { - type: "customJwt", - issuer: `https://api.workos.com/user_management/${clientId}`, - algorithm: "RS256", - jwks, - }, - { - type: "customJwt", - issuer: `https://api.workos.com/user_management/${clientId}/`, - algorithm: "RS256", - jwks, - }, - ], + providers: clientIds.flatMap((currentClientId) => { + const currentJwks = `https://api.workos.com/sso/jwks/${currentClientId}`; + return [ + { + type: "customJwt" as const, + issuer: "https://api.workos.com", + algorithm: "RS256" as const, + applicationID: currentClientId, + jwks: currentJwks, + }, + { + type: "customJwt" as const, + issuer: "https://api.workos.com/", + algorithm: "RS256" as const, + applicationID: currentClientId, + jwks: currentJwks, + }, + { + type: "customJwt" as const, + issuer: `https://api.workos.com/user_management/${currentClientId}`, + algorithm: "RS256" as const, + jwks: currentJwks, + }, + { + type: "customJwt" as const, + issuer: `https://api.workos.com/user_management/${currentClientId}/`, + algorithm: "RS256" as const, + jwks: currentJwks, + }, + ]; + }), } satisfies AuthConfig; diff --git a/tests/e2e/src/runner/auth.ts b/tests/e2e/src/runner/auth.ts index f7a6974..02e6854 100644 --- a/tests/e2e/src/runner/auth.ts +++ b/tests/e2e/src/runner/auth.ts @@ -73,9 +73,11 @@ function requireAuthCredentials( } async function pageHasSelector(page: AuthPage, selector: string) { - return await page.evaluate((querySelector) => { - return Boolean(document.querySelector(querySelector)); - }, selector); + try { + return await page.locator(selector).first().isVisible({ timeout: 1000 }); + } catch { + return false; + } } async function clickIfVisible(page: AuthPage, selector: string) { @@ -104,6 +106,13 @@ async function pageShowsSignInCallToAction(page: AuthPage): Promise { }); } +async function getPageTextPreview(page: AuthPage): Promise { + return await page.evaluate(() => { + const text = document.body?.innerText ?? ""; + return text.replace(/\s+/g, " ").trim().slice(0, 500); + }); +} + async function waitForDiagramsReturn( page: AuthPage, label: string, @@ -202,7 +211,9 @@ async function ensureCredentialsForm(page: AuthPage): Promise { { timeoutMs: 30_000, label: "workos-credentials-form" } ); if (!reachedCredentialsForm) { - throw new Error("WorkOS email input did not appear."); + throw new Error( + `WorkOS email input did not appear. url=${page.url()} text=${JSON.stringify(await getPageTextPreview(page))}` + ); } } From a637a1a8b57630cef747ae90893fb4ffccb97cb2 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 23:22:39 -0500 Subject: [PATCH 12/15] fix(ci): avoid unset Convex auth env --- packages/backend/convex/auth.config.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/backend/convex/auth.config.ts b/packages/backend/convex/auth.config.ts index 0c985d3..86a37ba 100644 --- a/packages/backend/convex/auth.config.ts +++ b/packages/backend/convex/auth.config.ts @@ -6,17 +6,13 @@ if (!clientId) { throw new Error("WORKOS_CLIENT_ID must be set for WorkOS AuthKit auth"); } -const previewWorkOsEnvironmentId = "environment_01KFPXKKS4QA3NP3VT0Q52WXB1"; const previewWorkOsClientId = "client_01KFPXKM905BYDQY5Q7BFJN409"; const clientIds = Array.from( new Set( [ clientId, - process.env.WORKOS_ENVIRONMENT_ID === previewWorkOsEnvironmentId || - process.env.VERCEL_ENV === "preview" - ? previewWorkOsClientId - : null, + process.env.VERCEL_ENV === "preview" ? previewWorkOsClientId : null, ].filter((value): value is string => Boolean(value)) ) ); From 5ff9d02e70dee06e035d4ba73913f8f96c4c8975 Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 23:26:43 -0500 Subject: [PATCH 13/15] fix(ci): keep preview auth fallback within Convex env limits --- packages/backend/convex/auth.config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/backend/convex/auth.config.ts b/packages/backend/convex/auth.config.ts index 86a37ba..1c82b7a 100644 --- a/packages/backend/convex/auth.config.ts +++ b/packages/backend/convex/auth.config.ts @@ -7,12 +7,18 @@ if (!clientId) { } const previewWorkOsClientId = "client_01KFPXKM905BYDQY5Q7BFJN409"; +const legacyPreviewAuthConfigClientId = "client_01KG0NZ3QX0AJQE87CKZC74YXQ"; const clientIds = Array.from( new Set( [ clientId, - process.env.VERCEL_ENV === "preview" ? previewWorkOsClientId : null, + // Convex auth config only permits env vars already set in Convex. + // During preview deploys, auth config currently sees the legacy client + // while Vercel/WorkOS mint tokens for the newer preview client. + clientId === legacyPreviewAuthConfigClientId + ? previewWorkOsClientId + : null, ].filter((value): value is string => Boolean(value)) ) ); From 3ed84c8129d6d745ab436793a84a7106fbde565b Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 23:32:26 -0500 Subject: [PATCH 14/15] fix(ci): preserve WorkOS JWT template expressions --- scripts/configure-workos-jwt-template.mjs | 332 +++++++++++++++--- .../configure-workos-jwt-template.test.mjs | 54 +++ 2 files changed, 344 insertions(+), 42 deletions(-) create mode 100644 scripts/configure-workos-jwt-template.test.mjs diff --git a/scripts/configure-workos-jwt-template.mjs b/scripts/configure-workos-jwt-template.mjs index 19b84ce..c81acd1 100644 --- a/scripts/configure-workos-jwt-template.mjs +++ b/scripts/configure-workos-jwt-template.mjs @@ -1,25 +1,21 @@ #!/usr/bin/env node +import { pathToFileURL } from "node:url"; + const WORKOS_API_BASE_URL = "https://api.workos.com"; const JWT_TEMPLATE_PATH = "/user_management/jwt_template"; +const WHITESPACE_PATTERN = /\s/; -const apiKey = process.env.WORKOS_API_KEY?.trim(); -const clientId = process.env.WORKOS_CLIENT_ID?.trim(); const dryRun = process.argv.includes("--dry-run"); -if (!apiKey) { - throw new Error( - "WORKOS_API_KEY must be set to configure WorkOS JWT template" - ); -} - -if (!clientId) { - throw new Error( - "WORKOS_CLIENT_ID must be set to configure WorkOS JWT template" - ); -} - async function requestWorkOs(path, options = {}) { + const apiKey = process.env.WORKOS_API_KEY?.trim(); + if (!apiKey) { + throw new Error( + "WORKOS_API_KEY must be set to configure WorkOS JWT template" + ); + } + const response = await fetch(`${WORKOS_API_BASE_URL}${path}`, { ...options, headers: { @@ -45,6 +41,252 @@ async function requestWorkOs(path, options = {}) { return payload; } +function isObject(value) { + return value && typeof value === "object" && !Array.isArray(value); +} + +function skipWhitespace(content, index) { + let currentIndex = index; + while ( + currentIndex < content.length && + WHITESPACE_PATTERN.test(content[currentIndex]) + ) { + currentIndex += 1; + } + return currentIndex; +} + +function readStringToken(content, index) { + if (content[index] !== '"') { + return null; + } + + let value = ""; + let currentIndex = index + 1; + while (currentIndex < content.length) { + const character = content[currentIndex]; + if (character === "\\") { + const nextCharacter = content[currentIndex + 1]; + if (nextCharacter === undefined) { + return null; + } + value += character + nextCharacter; + currentIndex += 2; + continue; + } + if (character === '"') { + try { + return { + end: currentIndex + 1, + value: JSON.parse(content.slice(index, currentIndex + 1)), + }; + } catch { + return { end: currentIndex + 1, value }; + } + } + value += character; + currentIndex += 1; + } + + return null; +} + +function skipTemplateExpression(content, index) { + if (!(content[index] === "{" && content[index + 1] === "{")) { + return index; + } + + const closeIndex = content.indexOf("}}", index + 2); + return closeIndex === -1 ? content.length : closeIndex + 2; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Template strings need a tiny scanner so unquoted WorkOS expressions are preserved. +function findObjectBounds(content) { + const start = skipWhitespace(content, 0); + if (content[start] !== "{") { + return null; + } + + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = start; index < content.length; index += 1) { + const character = content[index]; + + if (inString) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + + if (character === '"') { + inString = true; + continue; + } + + if (character === "{" && content[index + 1] === "{") { + index = skipTemplateExpression(content, index) - 1; + continue; + } + + if (character === "{") { + depth += 1; + continue; + } + + if (character === "}") { + depth -= 1; + if (depth === 0) { + return { end: index, start }; + } + } + } + + return null; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This only scans a single top-level JWT template property while respecting template expressions. +function findTopLevelAudValueRange(content, bounds) { + let depth = 1; + let index = bounds.start + 1; + + while (index < bounds.end) { + const character = content[index]; + + if (character === '"') { + const key = readStringToken(content, index); + if (!key) { + return null; + } + + const colonIndex = skipWhitespace(content, key.end); + if (depth === 1 && key.value === "aud" && content[colonIndex] === ":") { + const valueStart = skipWhitespace(content, colonIndex + 1); + let valueEnd = valueStart; + let valueDepth = 0; + let inValueString = false; + let escaped = false; + + while (valueEnd < bounds.end) { + const valueCharacter = content[valueEnd]; + + if (inValueString) { + if (escaped) { + escaped = false; + } else if (valueCharacter === "\\") { + escaped = true; + } else if (valueCharacter === '"') { + inValueString = false; + } + valueEnd += 1; + continue; + } + + if (valueCharacter === '"') { + inValueString = true; + valueEnd += 1; + continue; + } + + if (valueCharacter === "{" && content[valueEnd + 1] === "{") { + valueEnd = skipTemplateExpression(content, valueEnd); + continue; + } + + if (valueCharacter === "{" || valueCharacter === "[") { + valueDepth += 1; + } else if (valueCharacter === "}" || valueCharacter === "]") { + if (valueDepth === 0) { + break; + } + valueDepth -= 1; + } else if (valueDepth === 0 && valueCharacter === ",") { + break; + } + + valueEnd += 1; + } + + return { + end: content.slice(0, valueEnd).trimEnd().length, + start: valueStart, + }; + } + + index = key.end; + continue; + } + + if (character === "{" && content[index + 1] === "{") { + index = skipTemplateExpression(content, index); + continue; + } + + if (character === "{" || character === "[") { + depth += 1; + } else if (character === "}" || character === "]") { + depth -= 1; + } + + index += 1; + } + + return null; +} + +export function upsertAudInTemplateContent(content, aud) { + const audValue = JSON.stringify(aud); + + if (!(typeof content === "string" && content.trim().length > 0)) { + return JSON.stringify({ aud }); + } + + try { + const parsed = JSON.parse(content); + if (!isObject(parsed)) { + throw new Error( + "Existing WorkOS JWT template content is not a JSON object" + ); + } + return JSON.stringify({ + ...parsed, + aud, + }); + } catch (error) { + if ( + error instanceof Error && + !error.message.includes("JSON") && + !error.message.includes("position") + ) { + throw error; + } + } + + const bounds = findObjectBounds(content); + if (!bounds) { + throw new Error("Existing WorkOS JWT template content is not an object"); + } + + const audRange = findTopLevelAudValueRange(content, bounds); + if (audRange) { + return `${content.slice(0, audRange.start)}${audValue}${content.slice(audRange.end)}`; + } + + const beforeEnd = content.slice(0, bounds.end).trimEnd(); + const afterEnd = content.slice(bounds.end); + const hasExistingProperties = content + .slice(bounds.start + 1, bounds.end) + .trim(); + const prefix = hasExistingProperties ? "," : ""; + return `${beforeEnd}${prefix}\n "aud": ${audValue}\n${afterEnd}`; +} + async function readCurrentTemplate() { try { const payload = await requestWorkOs(JWT_TEMPLATE_PATH); @@ -59,13 +301,7 @@ async function readCurrentTemplate() { return {}; } - const parsed = JSON.parse(content); - if (!(parsed && typeof parsed === "object" && !Array.isArray(parsed))) { - throw new Error( - "Existing WorkOS JWT template content is not a JSON object" - ); - } - return parsed; + return content; } catch (error) { if ( error instanceof Error && @@ -79,28 +315,40 @@ async function readCurrentTemplate() { } } -const currentTemplate = dryRun ? {} : await readCurrentTemplate(); -const nextTemplate = { - ...currentTemplate, - aud: clientId, -}; -const nextContent = JSON.stringify(nextTemplate); +export async function main() { + const clientId = process.env.WORKOS_CLIENT_ID?.trim(); + if (!clientId) { + throw new Error( + "WORKOS_CLIENT_ID must be set to configure WorkOS JWT template" + ); + } -if (currentTemplate.aud === clientId) { - console.log(`WorkOS JWT template already has aud=${clientId}`); - process.exit(0); -} + const currentContent = dryRun ? "" : await readCurrentTemplate(); + const nextContent = upsertAudInTemplateContent(currentContent, clientId); -if (dryRun) { - console.log(nextContent); - process.exit(0); -} + if (currentContent === nextContent) { + console.log(`WorkOS JWT template already has aud=${clientId}`); + return; + } -await requestWorkOs(JWT_TEMPLATE_PATH, { - method: "PUT", - body: JSON.stringify({ - content: nextContent, - }), -}); + if (dryRun) { + console.log(nextContent); + return; + } + + await requestWorkOs(JWT_TEMPLATE_PATH, { + method: "PUT", + body: JSON.stringify({ + content: nextContent, + }), + }); -console.log(`Configured WorkOS JWT template aud=${clientId}`); + console.log(`Configured WorkOS JWT template aud=${clientId}`); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + await main(); +} diff --git a/scripts/configure-workos-jwt-template.test.mjs b/scripts/configure-workos-jwt-template.test.mjs new file mode 100644 index 0000000..752992d --- /dev/null +++ b/scripts/configure-workos-jwt-template.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { upsertAudInTemplateContent } from "./configure-workos-jwt-template.mjs"; + +describe("upsertAudInTemplateContent", () => { + it("creates a minimal template when no content exists", () => { + assert.equal( + upsertAudInTemplateContent("", "client_test"), + '{"aud":"client_test"}' + ); + }); + + it("preserves JSON templates while setting aud", () => { + assert.equal( + upsertAudInTemplateContent( + '{"urn:sketchi:role":"admin","aud":"old_client"}', + "client_test" + ), + '{"urn:sketchi:role":"admin","aud":"client_test"}' + ); + }); + + it("adds aud to WorkOS template expressions that are not strict JSON", () => { + assert.equal( + upsertAudInTemplateContent( + `{ + "urn:sketchi:email": {{ user.email }} +}`, + "client_test" + ), + `{ + "urn:sketchi:email": {{ user.email }}, + "aud": "client_test" +}` + ); + }); + + it("updates an existing aud value in template text", () => { + assert.equal( + upsertAudInTemplateContent( + `{ + "aud": {{ organization.metadata.audience }}, + "urn:sketchi:email": {{ user.email }} +}`, + "client_test" + ), + `{ + "aud": "client_test", + "urn:sketchi:email": {{ user.email }} +}` + ); + }); +}); From fc4b5a2f173a5e21333a22bc91a22dc6bf93a8be Mon Sep 17 00:00:00 2001 From: Anand Pant Date: Tue, 19 May 2026 23:44:27 -0500 Subject: [PATCH 15/15] fix(ci): sync WorkOS preview redirect with Vercel --- scripts/configure-workos-jwt-template.mjs | 57 +++++++++++++++++++ .../configure-workos-jwt-template.test.mjs | 36 +++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/scripts/configure-workos-jwt-template.mjs b/scripts/configure-workos-jwt-template.mjs index c81acd1..e84733d 100644 --- a/scripts/configure-workos-jwt-template.mjs +++ b/scripts/configure-workos-jwt-template.mjs @@ -4,6 +4,9 @@ import { pathToFileURL } from "node:url"; const WORKOS_API_BASE_URL = "https://api.workos.com"; const JWT_TEMPLATE_PATH = "/user_management/jwt_template"; +const REDIRECT_URIS_PATH = "/user_management/redirect_uris"; +const CORS_ORIGINS_PATH = "/user_management/cors_origins"; +const ALREADY_CONFIGURED_PATTERN = /\b(already|duplicate|exists|taken)\b/i; const WHITESPACE_PATTERN = /\s/; const dryRun = process.argv.includes("--dry-run"); @@ -41,6 +44,58 @@ async function requestWorkOs(path, options = {}) { return payload; } +function isAlreadyConfiguredError(error) { + return ( + error instanceof Error && ALREADY_CONFIGURED_PATTERN.test(error.message) + ); +} + +async function createIfMissing(path, body, label) { + try { + await requestWorkOs(path, { + method: "POST", + body: JSON.stringify(body), + }); + console.log(`${label} added`); + } catch (error) { + if (isAlreadyConfiguredError(error)) { + console.log(`${label} already configured`); + return; + } + throw error; + } +} + +export function getVercelPreviewOrigin() { + const vercelUrl = process.env.VERCEL_URL?.trim(); + if (!vercelUrl) { + return null; + } + + const url = vercelUrl.startsWith("http") + ? new URL(vercelUrl) + : new URL(`https://${vercelUrl}`); + return url.origin; +} + +async function configurePreviewAuthKitUrls() { + const previewOrigin = getVercelPreviewOrigin(); + if (!previewOrigin) { + return; + } + + await createIfMissing( + REDIRECT_URIS_PATH, + { uri: `${previewOrigin}/callback` }, + `AuthKit redirect URI ${previewOrigin}/callback` + ); + await createIfMissing( + CORS_ORIGINS_PATH, + { origin: previewOrigin }, + `AuthKit CORS origin ${previewOrigin}` + ); +} + function isObject(value) { return value && typeof value === "object" && !Array.isArray(value); } @@ -336,6 +391,8 @@ export async function main() { return; } + await configurePreviewAuthKitUrls(); + await requestWorkOs(JWT_TEMPLATE_PATH, { method: "PUT", body: JSON.stringify({ diff --git a/scripts/configure-workos-jwt-template.test.mjs b/scripts/configure-workos-jwt-template.test.mjs index 752992d..ff7f952 100644 --- a/scripts/configure-workos-jwt-template.test.mjs +++ b/scripts/configure-workos-jwt-template.test.mjs @@ -1,7 +1,16 @@ import assert from "node:assert/strict"; -import { describe, it } from "node:test"; +import { afterEach, describe, it } from "node:test"; -import { upsertAudInTemplateContent } from "./configure-workos-jwt-template.mjs"; +import { + getVercelPreviewOrigin, + upsertAudInTemplateContent, +} from "./configure-workos-jwt-template.mjs"; + +const originalVercelUrl = process.env.VERCEL_URL; + +afterEach(() => { + process.env.VERCEL_URL = originalVercelUrl; +}); describe("upsertAudInTemplateContent", () => { it("creates a minimal template when no content exists", () => { @@ -52,3 +61,26 @@ describe("upsertAudInTemplateContent", () => { ); }); }); + +describe("getVercelPreviewOrigin", () => { + it("returns null without a Vercel URL", () => { + process.env.VERCEL_URL = ""; + assert.equal(getVercelPreviewOrigin(), null); + }); + + it("normalizes Vercel hostnames to HTTPS origins", () => { + process.env.VERCEL_URL = "sketchi-preview.vercel.app"; + assert.equal( + getVercelPreviewOrigin(), + "https://sketchi-preview.vercel.app" + ); + }); + + it("preserves fully qualified preview origins", () => { + process.env.VERCEL_URL = "https://sketchi-preview.vercel.app/some-path"; + assert.equal( + getVercelPreviewOrigin(), + "https://sketchi-preview.vercel.app" + ); + }); +});