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..b67601a 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -27,13 +27,19 @@ 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') )) && (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 +48,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 +92,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..e680718 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 + - name: Check preview test policy id: doc_check - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const { owner, repo } = context.repo; @@ -31,9 +31,9 @@ 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"); - return; + throw new Error( + "No deployment SHA found; cannot evaluate E2E API policy." + ); } let prs = []; try { @@ -42,20 +42,32 @@ 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", "true"); - return; + throw new Error( + `Failed to resolve PR for ${sha}: ${error.message}` + ); } if (!prs.length) { - core.notice(`No PR found for ${sha}; running tests.`); - core.setOutput("run_tests", "true"); - return; + throw new Error( + `No PR found for ${sha}; cannot evaluate E2E API policy.` + ); } 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"); @@ -84,7 +96,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 +221,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..3e98a05 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 + - name: Check preview test policy id: doc_check - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const { owner, repo } = context.repo; @@ -31,9 +31,9 @@ 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"); - return; + throw new Error( + "No deployment SHA found; cannot evaluate E2E web policy." + ); } let prs = []; try { @@ -42,20 +42,32 @@ 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", "true"); - return; + throw new Error( + `Failed to resolve PR for ${sha}: ${error.message}` + ); } if (!prs.length) { - core.notice(`No PR found for ${sha}; running tests.`); - core.setOutput("run_tests", "true"); - return; + throw new Error( + `No PR found for ${sha}; cannot evaluate E2E web policy.` + ); } 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"); @@ -84,7 +96,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 +247,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 +373,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..2d1ee6b 100644 --- a/.github/workflows/opencode-excalidraw-pr.yml +++ b/.github/workflows/opencode-excalidraw-pr.yml @@ -17,12 +17,21 @@ permissions: jobs: opencode-excalidraw: - runs-on: blacksmith-2vcpu-ubuntu-2404 + 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" 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 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..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 ../../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/packages/backend/convex/auth.config.ts b/packages/backend/convex/auth.config.ts index 45faa59..1c82b7a 100644 --- a/packages/backend/convex/auth.config.ts +++ b/packages/backend/convex/auth.config.ts @@ -6,35 +6,53 @@ 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 previewWorkOsClientId = "client_01KFPXKM905BYDQY5Q7BFJN409"; +const legacyPreviewAuthConfigClientId = "client_01KG0NZ3QX0AJQE87CKZC74YXQ"; + +const clientIds = Array.from( + new Set( + [ + clientId, + // 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)) + ) +); 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/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/scripts/configure-workos-jwt-template.mjs b/scripts/configure-workos-jwt-template.mjs new file mode 100644 index 0000000..e84733d --- /dev/null +++ b/scripts/configure-workos-jwt-template.mjs @@ -0,0 +1,411 @@ +#!/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 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"); + +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: { + 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; +} + +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); +} + +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); + 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 {}; + } + + return content; + } catch (error) { + if ( + error instanceof Error && + error.message.includes("WorkOS GET") && + (error.message.includes("404") || + error.message.includes("JWT template not found")) + ) { + return {}; + } + throw error; + } +} + +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" + ); + } + + const currentContent = dryRun ? "" : await readCurrentTemplate(); + const nextContent = upsertAudInTemplateContent(currentContent, clientId); + + if (currentContent === nextContent) { + console.log(`WorkOS JWT template already has aud=${clientId}`); + return; + } + + if (dryRun) { + console.log(nextContent); + return; + } + + await configurePreviewAuthKitUrls(); + + await requestWorkOs(JWT_TEMPLATE_PATH, { + method: "PUT", + body: JSON.stringify({ + content: nextContent, + }), + }); + + 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..ff7f952 --- /dev/null +++ b/scripts/configure-workos-jwt-template.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; + +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", () => { + 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 }} +}` + ); + }); +}); + +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" + ); + }); +}); diff --git a/scripts/prepare-convex-workos-auth.sh b/scripts/prepare-convex-workos-auth.sh new file mode 100644 index 0000000..f18ffc0 --- /dev/null +++ b/scripts/prepare-convex-workos-auth.sh @@ -0,0 +1,14 @@ +#!/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 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 diff --git a/tests/e2e/src/runner/auth.ts b/tests/e2e/src/runner/auth.ts index 6909801..02e6854 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; @@ -46,10 +60,24 @@ 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)); - }, selector); + try { + return await page.locator(selector).first().isVisible({ timeout: 1000 }); + } catch { + return false; + } } async function clickIfVisible(page: AuthPage, selector: string) { @@ -78,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, @@ -124,7 +159,16 @@ async function continueFromSignedInPage( page: AuthPage, baseUrl: string ): Promise { - const clicked = await clickIfVisible(page, 'a:has-text("Continue")'); + const hasCredentialsForm = + (await pageHasSelector(page, emailInputSelector)) || + (await pageHasSelector(page, passwordInputSelector)); + if (hasCredentialsForm) { + return false; + } + + const clicked = + (await clickIfVisible(page, 'a:has-text("Continue")')) || + (await clickIfVisible(page, 'button:has-text("Continue")')); if (!clicked) { return false; } @@ -158,29 +202,31 @@ 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" } ); 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))}` + ); } } 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; @@ -193,13 +239,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" } ); } @@ -208,7 +254,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; @@ -216,6 +262,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 @@ -247,25 +308,22 @@ 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); if (!page.url().includes("/diagrams") || stillShowsSignInCta) { await openHostedSignInIfNeeded(page); - if (!(credentials.email && credentials.password)) { - throw new Error( - "Diagram Studio requires sign-in. Set SKETCHI_E2E_EMAIL and SKETCHI_E2E_PASSWORD for authenticated E2E." - ); + 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 ensureCredentialsForm(page); - await submitEmailStep(page, credentials.email); - await submitPasswordStep(page, credentials.password); - await waitForDiagramsReturn(page, "auth-return-diagrams", baseUrl); } const finalSignInCta = await pageShowsSignInCallToAction(page);