From 40bb5b3999f1cd33a6b3b0b433e674cc31316784 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Sun, 20 Sep 2026 20:39:50 +0000 Subject: [PATCH] feat(auth): replace Linear login with verified native accounts --- .github/workflows/ci.yml | 14 ++ .github/workflows/deploy.yml | 17 ++ README.md | 32 ++- packages/app/.dev.vars.example | 7 +- .../app/migrations/0008_native_accounts.sql | 17 ++ packages/app/package.json | 5 +- packages/app/public/styles.css | 2 +- .../app/scripts/validate-auth-email.check.mjs | 21 ++ packages/app/scripts/validate-auth-email.mjs | 25 +++ packages/app/src/auth-email.ts | 24 +++ packages/app/src/auth.ts | 3 + packages/app/src/device-oauth.ts | 2 +- packages/app/src/index.ts | 86 +++++--- packages/app/src/postgres/auth-repository.ts | 126 ++++++++--- .../app/src/postgres/connection-repository.ts | 15 ++ .../app/src/postgres/identity-repository.ts | 2 +- packages/app/src/postgres/schema.ts | 7 +- packages/app/src/types.ts | 4 +- packages/app/src/ui.ts | 30 ++- packages/app/test/device-oauth.test.ts | 2 +- .../app/test/native-auth.postgres.test.ts | 197 ++++++++++++++++++ packages/docs/api/authentication.mdx | 6 + packages/docs/integrations/oauth.mdx | 2 +- packages/docs/quickstart.mdx | 4 +- 24 files changed, 566 insertions(+), 84 deletions(-) create mode 100644 packages/app/migrations/0008_native_accounts.sql create mode 100644 packages/app/scripts/validate-auth-email.check.mjs create mode 100644 packages/app/scripts/validate-auth-email.mjs create mode 100644 packages/app/src/auth-email.ts create mode 100644 packages/app/test/native-auth.postgres.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b2322c..1e1993d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,20 @@ jobs: name: Test and validate Workers runs-on: ubuntu-latest timeout-minutes: 10 + services: + postgres: + image: postgres:17 + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + AUTH_TEST_DATABASE_URL: postgresql://postgres@localhost:5432/postgres steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9d49e4b..1468cd5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,10 +27,27 @@ jobs: cache: npm - run: npm ci - run: npm run build:css + - name: Validate authentication email configuration + run: npm run auth:validate-email --workspace=factorize + env: + APP_ORIGIN: https://app.factorize.sh + POSTMARK_SERVER_TOKEN: ${{ secrets.POSTMARK_SERVER_TOKEN }} + POSTMARK_FROM_EMAIL: ${{ secrets.POSTMARK_FROM_EMAIL }} + POSTMARK_MESSAGE_STREAM: ${{ secrets.POSTMARK_MESSAGE_STREAM }} - name: Apply database migrations run: npm run db:migrate --workspace=factorize env: DATABASE_URL: ${{ secrets.DATABASE_URL }} + - name: Configure authentication email Worker secrets + working-directory: packages/app + run: | + node -e 'process.stdout.write(JSON.stringify(Object.fromEntries(["POSTMARK_SERVER_TOKEN", "POSTMARK_FROM_EMAIL", "POSTMARK_MESSAGE_STREAM"].map(key => [key, process.env[key]]))))' | npx wrangler secret bulk + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + POSTMARK_SERVER_TOKEN: ${{ secrets.POSTMARK_SERVER_TOKEN }} + POSTMARK_FROM_EMAIL: ${{ secrets.POSTMARK_FROM_EMAIL }} + POSTMARK_MESSAGE_STREAM: ${{ secrets.POSTMARK_MESSAGE_STREAM }} - name: Deploy app Worker run: npm run deploy:app env: diff --git a/README.md b/README.md index cfde2b0..3c4df11 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,33 @@ Fill `.dev.vars` with the Linear OAuth credentials, webhook signing secret, and openssl rand -base64 32 ``` -## Configure Linear - ## Account authentication -Factorize stores first-party accounts in the `AuthStore` Durable Object. Users can create an account and sign in with an email address and a password of at least 12 characters at `/auth/signup` and `/auth/login`; the existing Linear connection remains available from the same sign-in page. Passwords are salted PBKDF2-SHA-256 hashes and reset tokens are single-use, expire after one hour, and revoke existing sessions when consumed. +Factorize owns authentication in PostgreSQL. Create a native account at `/auth/signup` with a unique username (3–32 letters, numbers, underscores or hyphens), an email address, and a password of 12–200 characters. Usernames and emails are case-insensitive. Verify the email before signing in at `/auth/login`; username or email plus password are accepted. Linear is connected after sign-in from **Settings → Integrations**, only for Linear-backed jobs. + +Postmark sends one-hour, single-use verification and password-reset links. `/auth/verify/request` resends verification; `/auth/password-reset` handles recovery. Links never appear in application responses or logs. Passwords use salted PBKDF2-SHA-256. **Change password** in the account menu requires the current password. Password reset, password change and sign-out invalidate existing sessions via membership session versions, including delegated API credentials. + +Before deploying, configure these **production GitHub environment secrets** (the deployment copies them to Worker secrets): + +| Setting | Required value | +| --- | --- | +| `POSTMARK_SERVER_TOKEN` | Postmark **server** API token for the sending server; never an account token or a committed value | +| `POSTMARK_FROM_EMAIL` | Plain email address on a verified Postmark sender signature or verified domain | +| `POSTMARK_MESSAGE_STREAM` | Active **transactional** stream ID, commonly `outbound` | + +`APP_ORIGIN` must be the canonical HTTPS origin, without a trailing slash. Deploy runs `npm run auth:validate-email --workspace=factorize` **before migrations**: it checks the server token/stream with Postmark and sends one preflight email from/to the configured sender, rejecting unverified senders or sending failures. It prints no credentials. Missing configuration stops deployment. Runtime signup and recovery also fail closed if configuration or delivery fails; users can resend verification after a delivery failure. Local mail testing needs an HTTPS origin and a separate Postmark test server/sender; automated tests mock Postmark and never send mail. -The `v4-auth-store` Durable Object migration must be deployed before using first-party authentication. Configure `SESSION_SIGNING_SECRET` as usual. For local development only, `AUTH_RESET_RETURN_TOKEN=true` makes the reset endpoint return its token so the flow can be exercised without an email provider; leave it unset or false in production. Transactional email delivery can consume reset events from the auth store without exposing whether an address is registered. +### Existing account migration + +Apply `0008_native_accounts.sql` before deploying this code. It rejects ambiguous legacy emails that differ only in case (reconcile those identities before retrying migration), preserves tenant IDs, memberships, jobs and encrypted Linear connections, and backfills explicit Linear organization-to-workspace mappings. Existing Linear-only users choose **Forgot password or previously signed in with Linear?**, receive a reset email at their existing address, and choose a username and password. Email possession is required; signup cannot replace an existing account's credentials. Existing native users keep their passwords and may sign in by email; unverified accounts must verify first. A reset never promotes a member to owner. An account with multiple existing owner memberships currently opens the oldest workspace; no new workspace-switching UI is introduced. + +Linear OAuth requires an authenticated owner, binds state to that user/workspace, stores the connection there, and never creates a Factorize session. A Linear organization already bound to another workspace cannot be claimed. Existing webhook routes resolve the explicit mapping. Do not delete that binding to work around an account-recovery problem. + +### Authentication verification + +`npm run check` runs unit/contract tests and Postmark preflight tests. CI also runs the real PostgreSQL authentication suite against a disposable service. To run it locally, set `AUTH_TEST_DATABASE_URL` to an isolated PostgreSQL server whose user can create/drop test databases, then run `npm test --workspace=factorize -- --run test/native-auth.postgres.test.ts`. The suite creates and removes its own database and applies every migration, including a legacy connection fixture before the auth migration. + +## Configure Linear Use authorization-code OAuth and enable **Webhooks** on the OAuth application. Configure: @@ -147,7 +167,7 @@ Read the complete, implementation-accurate API and MCP documentation at [docs.fa API clients use OAuth 2.1 authorization code flow with PKCE S256 or the OAuth 2.0 Device Authorization Grant for headless environments. Factorize publishes authorization-server and protected-resource discovery metadata, supports Client ID Metadata Documents, and retains dynamic client registration at `/oauth/register` for older clients. Access tokens last one hour and may be refreshed for up to 30 days; RFC 7009 revocation is advertised by discovery metadata. -Available scopes are `flows:read`, `flows:write`, `runs:read`, and `runs:write`. The resource owner must sign in through the normal Linear-backed Factorize session and explicitly approve the requested scopes. Factorize rechecks owner membership and session version on every service call. +Available scopes are `flows:read`, `flows:write`, `runs:read`, and `runs:write`. The resource owner must sign in through the native Factorize session and explicitly approve the requested scopes. Factorize rechecks owner membership and session version on every service call. The versioned API is rooted at `/api/v1`: @@ -186,7 +206,7 @@ Configure a compatible remote MCP client with this single URL: https://app.factorize.sh/mcp ``` -The client discovers OAuth automatically, opens Factorize in a browser, completes Linear sign-in if necessary, requests consent, and returns to the client after PKCE authorization. No Linear or exe.dev credential is copied into the MCP client. The server is stateless Streamable HTTP and exposes Job CRUD, run inspection/filtering, provider webhook activity, and active-run stopping tools. +The client discovers OAuth automatically, opens Factorize in a browser, completes native Factorize sign-in if necessary, requests consent, and returns to the client after PKCE authorization. No Linear or exe.dev credential is copied into the MCP client. The server is stateless Streamable HTTP and exposes Job CRUD, run inspection/filtering, provider webhook activity, and active-run stopping tools. Factorize also supports the OAuth 2.0 Device Authorization Grant (RFC 8628) for headless clients. Discovery advertises `device_authorization_endpoint`; clients obtain a code from `POST /oauth/device_authorization`, direct the user to `/device`, and poll `/oauth/token` with grant type `urn:ietf:params:oauth:grant-type:device_code`. Device codes expire after ten minutes, polling is rate-limited, and approved grants use the same scoped access and refresh tokens, tenant checks, and revocation behavior as browser PKCE authorization. diff --git a/packages/app/.dev.vars.example b/packages/app/.dev.vars.example index f107acb..24ca72d 100644 --- a/packages/app/.dev.vars.example +++ b/packages/app/.dev.vars.example @@ -9,5 +9,8 @@ CLICKUP_CLIENT_SECRET= CREDENTIAL_ENCRYPTION_KEY= # a separate random value for signed browser sessions SESSION_SIGNING_SECRET= -# Set only in local development to return reset tokens in the response. Keep unset in production. -AUTH_RESET_RETURN_TOKEN=false +# Use an HTTPS APP_ORIGIN and a separate Postmark server for development. +POSTMARK_SERVER_TOKEN= +# Must be verified in Postmark (plain email address). +POSTMARK_FROM_EMAIL= +POSTMARK_MESSAGE_STREAM=outbound diff --git a/packages/app/migrations/0008_native_accounts.sql b/packages/app/migrations/0008_native_accounts.sql new file mode 100644 index 0000000..c15f81b --- /dev/null +++ b/packages/app/migrations/0008_native_accounts.sql @@ -0,0 +1,17 @@ +-- Fail closed on ambiguous legacy email identities; reconcile any duplicates before deployment. +CREATE UNIQUE INDEX auth_users_email_normalized ON app.auth_users(lower(email)); +ALTER TABLE app.auth_users ADD COLUMN username text UNIQUE; +ALTER TABLE app.auth_users ADD CONSTRAINT auth_users_username_check CHECK (username IS NULL OR username ~ '^[a-z0-9_][a-z0-9_-]{2,31}$'); +ALTER TABLE app.auth_reset_tokens ADD COLUMN purpose text NOT NULL DEFAULT 'reset' CHECK (purpose IN ('verify','reset')); +CREATE TABLE app.linear_workspaces ( + organization_id uuid PRIMARY KEY, + tenant_id uuid NOT NULL UNIQUE REFERENCES app.tenants(id) ON DELETE CASCADE +); +-- Legacy Linear login used the organization UUID as the tenant UUID. Preserve +-- these bindings and all existing encrypted credentials and tenant data. +INSERT INTO app.linear_workspaces(organization_id,tenant_id) +SELECT tenant_id,tenant_id FROM app.connections WHERE kind='linear'; + +-- Retire browser/API sessions issued to unverified users by the old signup path. +UPDATE app.members m SET session_version=session_version+1 +FROM app.auth_users u WHERE u.id=m.user_id AND NOT u.email_verified; diff --git a/packages/app/package.json b/packages/app/package.json index 63e2a6d..4634c17 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -6,13 +6,14 @@ "scripts": { "dev": "npm run build:css && wrangler dev", "build:css": "tailwindcss -i ./src/tailwind.css -o ./public/styles.css --minify", - "check": "npm run check:api-boundaries && tsc --noEmit && vitest run && wrangler deploy --dry-run", + "check": "npm run check:api-boundaries && node --test scripts/validate-auth-email.check.mjs && tsc --noEmit && vitest run && wrangler deploy --dry-run", "check:api-boundaries": "node scripts/check-api-boundaries.mjs", "test": "vitest run", "deploy": "wrangler deploy", "db:migrate": "node scripts/migrate.mjs", "db:verify": "node scripts/verify-schema.mjs", - "db:studio": "drizzle-kit studio --config drizzle.config.ts" + "db:studio": "drizzle-kit studio --config drizzle.config.ts", + "auth:validate-email": "node scripts/validate-auth-email.mjs" }, "dependencies": { "@cloudflare/workers-oauth-provider": "^0.10.3", diff --git a/packages/app/public/styles.css b/packages/app/public/styles.css index e73065c..004accb 100644 --- a/packages/app/public/styles.css +++ b/packages/app/public/styles.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-800:oklch(47% .157 37.304);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-950:oklch(26.6% .065 152.934);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-slate-100:#f5f1e8;--color-slate-200:#e7e1d4;--color-slate-300:#cec5b4;--color-slate-400:#9b9282;--color-slate-500:#71695e;--color-slate-600:#575047;--color-slate-700:#403b34;--color-slate-800:#2c2924;--color-slate-900:#201e1a;--color-slate-950:#151410;--color-stone-50:#fcfbf6;--color-stone-100:#f6f3e9;--color-stone-200:#e6e0d1;--color-stone-300:#d4cbb9;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-factorize-50:#fff9e6;--color-factorize-100:#fff0b8;--color-factorize-500:#fdc901;--color-factorize-600:#896300;--color-factorize-700:#705000}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{scrollbar-gutter:stable}body>main{flex:1;width:100%;min-width:0}body>footer{padding-bottom:calc(1.5rem + env(safe-area-inset-bottom))}}@layer components{.field{margin-top:calc(var(--spacing) * 1.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-slate-300);background-color:var(--color-white);width:100%;min-width:0;max-width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);color:var(--color-slate-950);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-outline-style:none;outline-style:none}.field::placeholder{color:var(--color-slate-400)}.field:focus{border-color:var(--color-factorize-500);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:#fdc90126}@supports (color:color-mix(in lab, red, red)){.field:focus{--tw-ring-color:color-mix(in oklab, var(--color-factorize-500) 15%, transparent)}}.field:where(.dark,.dark *){border-color:var(--color-slate-700);background-color:var(--color-slate-950);color:var(--color-white)}.field:where(.dark,.dark *)::placeholder{color:var(--color-slate-500)}select.field:not([multiple]){appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%23575047' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5'%3E%3Cpath d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .75rem center;background-repeat:no-repeat;background-size:1.25rem 1.25rem;padding-right:2.75rem}.dark select.field:not([multiple]){background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%23cec5b4' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5'%3E%3Cpath d='m6 8 4 4 4-4'/%3E%3C/svg%3E")}.label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--color-slate-700);display:block}.label:where(.dark,.dark *){color:var(--color-slate-300)}.template-editor{min-width:0;max-width:100%;position:relative}.template-editor textarea,.template-editor pre{scrollbar-gutter:stable;white-space:pre-wrap;overflow-wrap:break-word;min-height:11rem;font-family:var(--font-mono);font-size:var(--text-sm);tab-size:4;margin:0;padding:.625rem .75rem;line-height:1.25rem;overflow:auto}.template-editor pre{pointer-events:none;color:var(--color-slate-950);border:1px solid #0000;position:absolute;inset:0}.template-editor textarea{resize:vertical;color:#0000;caret-color:var(--color-slate-950);-webkit-text-fill-color:transparent;background-color:#0000;position:relative}.template-editor textarea::placeholder{color:var(--color-slate-400);-webkit-text-fill-color:var(--color-slate-400)}.template-delimiter{color:var(--color-factorize-600);font-weight:700}.template-expression{color:#2563eb}.dark .template-editor pre{color:var(--color-slate-100)}.dark .template-editor textarea{caret-color:var(--color-slate-100)}.dark .template-delimiter{color:var(--color-factorize-500)}.dark .template-expression{color:#93c5fd}.navbar-bee{transition:filter .15s}.dark .navbar-bee{filter:invert()}.landing-honeycomb{background-color:var(--color-stone-50);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='720' height='520' viewBox='0 0 720 520'%3E%3Cg transform='rotate(-7 360 260)' fill='none' stroke='%23705000' stroke-opacity='.07' stroke-width='1.5'%3E%3Cpath d='M25 140 87.5 31.75h125L275 140l-62.5 108.25h-125zM385 380l62.5-108.25h125L635 380l-62.5 108.25h-125z'/%3E%3C/g%3E%3C/svg%3E");background-position:top;background-size:720px 520px}.dark .landing-honeycomb{background-color:var(--color-slate-950);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='720' height='520' viewBox='0 0 720 520'%3E%3Cg transform='rotate(-7 360 260)' fill='none' stroke='%23fdc901' stroke-opacity='.05' stroke-width='1.5'%3E%3Cpath d='M25 140 87.5 31.75h125L275 140l-62.5 108.25h-125zM385 380l62.5-108.25h125L635 380l-62.5 108.25h-125z'/%3E%3C/g%3E%3C/svg%3E")}dialog[id$=-setup]{max-height:calc(100dvh - 2rem);margin:auto;overflow-y:auto}#trigger-dialog{width:min(44rem,100vw - 2rem);max-width:calc(100vw - 2rem)}#trigger-dialog>form,#trigger-dialog>form>div{min-width:0;max-width:100%}#trigger-rows td{overflow-wrap:anywhere}@media (min-width:40rem){div:has(>label>select[name=agentKind]){grid-template-columns:max-content minmax(0,1fr)}div:has(>label>select[name=agentKind])>label:first-child{width:11rem}}.trace-list{--trace-surface:#f6f3e9;--trace-border:#e6e0d1;--trace-muted:#71695e;--trace-accent:#705000;--trace-code:#fcfbf6;color:#201e1a}.dark .trace-list{--trace-surface:#2c2924;--trace-border:#403b34;--trace-muted:#b2a897;--trace-accent:#f4d36c;--trace-code:#151410;color:#f5f1e8}.trace-list>*+*{margin-top:1rem}.trace-message{min-width:0;padding:1rem 0}.trace-message h3{color:var(--trace-accent);letter-spacing:.08em;text-transform:uppercase;margin-bottom:.65rem;font-size:.7rem;font-weight:700}.trace-user_message{border-left:3px solid var(--trace-accent);background:var(--trace-surface);border-radius:0 .6rem .6rem 0;padding:1rem 1.25rem}.trace-prose{overflow-wrap:anywhere;font-size:.875rem;line-height:1.75}.trace-line{white-space:pre-wrap}.trace-prose h4{margin:.75rem 0 .25rem;font-weight:700}.trace-prose code{background:var(--trace-surface);border-radius:.25rem;padding:.1rem .3rem;font-size:.85em}.trace-bullet{padding-left:1.25rem;position:relative}.trace-bullet:before{content:"•";color:var(--trace-muted);position:absolute;left:.25rem}.trace-event,.trace-structured{border:1px solid var(--trace-border);border-radius:.6rem;min-width:0;overflow:hidden}.trace-event summary,.trace-structured summary{cursor:pointer;overflow-wrap:anywhere;padding:.75rem 1rem;font-size:.8rem}.trace-event summary:focus-visible,.trace-structured summary:focus-visible{outline:2px solid var(--trace-accent);outline-offset:-2px}.trace-event summary{background:var(--trace-surface)}.trace-event-kind{color:var(--trace-muted);text-transform:uppercase;letter-spacing:.04em;margin-right:.75rem;font-size:.65rem;display:inline-block}.trace-meta{border-style:dashed}.trace-error{border-color:#dc2626}.trace-warning{border-color:#b7791f}.trace-structured{margin:.75rem 0}.trace-structured>div{padding:0 1rem 1rem}.trace-code{background:var(--trace-code);min-width:0}.trace-prose .trace-code{border:1px solid var(--trace-border);border-radius:.5rem;margin:.75rem 0;overflow:hidden}.trace-code-label{border-bottom:1px solid var(--trace-border);color:var(--trace-muted);font:600 .65rem var(--font-mono);padding:.4rem 1rem}.trace-code pre{white-space:pre;max-height:24rem;font:.75rem/1.7 var(--font-mono);tab-size:2;margin:0;padding:1rem;overflow:auto}.trace-code code{font-size:inherit;background:0 0;padding:0}.trace-token-keyword{color:#7e22ce}.trace-token-string{color:#166534}.trace-token-comment{color:#71695e;font-style:italic}.trace-token-number,.trace-token-variable{color:#075985}.dark .trace-token-keyword{color:#d8b4fe}.dark .trace-token-string{color:#86efac}.dark .trace-token-comment{color:#b2a897}.dark .trace-token-number,.dark .trace-token-variable{color:#7dd3fc}.trace-thinking{color:var(--trace-muted);align-items:center;gap:.5rem;padding:.25rem 0;font-size:.8rem;display:flex}.trace-spinner{border:2px solid var(--trace-border);border-top-color:var(--trace-accent);border-radius:50%;width:.8rem;height:.8rem}.trace-thinking-live{display:none}.trace-running .trace-at-end>.trace-thinking:last-child .trace-spinner{animation:1s linear infinite trace-spin}.trace-running .trace-at-end>.trace-thinking:last-child .trace-thinking-live{display:inline}.trace-running .trace-at-end>.trace-thinking:last-child .trace-thinking-past{display:none}@keyframes trace-spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.trace-running .trace-at-end>.trace-thinking:last-child .trace-spinner{animation:none}}}@layer utilities{.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-y-0{inset-block:0}.top-2\.5{top:calc(var(--spacing) * 2.5)}.top-5{top:calc(var(--spacing) * 5)}.right-0{right:0}.right-2\.5{right:calc(var(--spacing) * 2.5)}.right-3{right:calc(var(--spacing) * 3)}.right-5{right:calc(var(--spacing) * 5)}.z-20{z-index:20}.m-auto{margin:auto}.mx-auto{margin-inline:auto}.mt-0{margin-top:0}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-9{margin-top:calc(var(--spacing) * 9)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-24{margin-top:calc(var(--spacing) * 24)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-16{height:calc(var(--spacing) * 16)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[70vh\]{max-height:70vh}.max-h-\[min\(60vh\,32rem\)\]{max-height:min(60vh,32rem)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-12{min-height:calc(var(--spacing) * 12)}.min-h-24{min-height:calc(var(--spacing) * 24)}.min-h-32{min-height:calc(var(--spacing) * 32)}.min-h-36{min-height:calc(var(--spacing) * 36)}.min-h-44{min-height:calc(var(--spacing) * 44)}.min-h-\[100dvh\]{min-height:100dvh}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-56{width:calc(var(--spacing) * 56)}.w-\[min\(32rem\,calc\(100vw-4rem\)\)\]{width:min(32rem,100vw - 4rem)}.w-\[min\(34rem\,calc\(100\%-2rem\)\)\]{width:min(34rem,100% - 2rem)}.w-\[min\(42rem\,calc\(100\%-2rem\)\)\]{width:min(42rem,100% - 2rem)}.w-\[min\(42rem\,calc\(100vw-2rem\)\)\]{width:min(42rem,100vw - 2rem)}.w-\[min\(44rem\,calc\(100\%-2rem\)\)\]{width:min(44rem,100% - 2rem)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-36{min-width:calc(var(--spacing) * 36)}.min-w-\[48rem\]{min-width:48rem}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-stone-200>:not(:last-child)){border-color:var(--color-stone-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-300{border-color:var(--color-amber-300)}.border-amber-400{border-color:var(--color-amber-400)}.border-red-200{border-color:var(--color-red-200)}.border-stone-200{border-color:var(--color-stone-200)}.border-stone-300{border-color:var(--color-stone-300)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-factorize-50{background-color:var(--color-factorize-50)}.bg-factorize-100{background-color:var(--color-factorize-100)}.bg-factorize-500{background-color:var(--color-factorize-500)}.bg-green-100{background-color:var(--color-green-100)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-slate-950{background-color:var(--color-slate-950)}.bg-stone-50{background-color:var(--color-stone-50)}.bg-stone-100{background-color:var(--color-stone-100)}.bg-stone-200{background-color:var(--color-stone-200)}.bg-stone-300{background-color:var(--color-stone-300)}.bg-white{background-color:var(--color-white)}.object-contain{object-fit:contain}.p-0{padding:0}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-1{padding-top:var(--spacing)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pr-28{padding-right:calc(var(--spacing) * 28)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-8{--tw-leading:calc(var(--spacing) * 8);line-height:calc(var(--spacing) * 8)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-amber-950{color:var(--color-amber-950)}.text-blue-800{color:var(--color-blue-800)}.text-factorize-600{color:var(--color-factorize-600)}.text-factorize-700{color:var(--color-factorize-700)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-orange-800{color:var(--color-orange-800)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-slate-100{color:var(--color-slate-100)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-950{color:var(--color-slate-950)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline{text-decoration-line:underline}.decoration-slate-400\/60{text-decoration-color:#9b928299}@supports (color:color-mix(in lab, red, red)){.decoration-slate-400\/60{-webkit-text-decoration-color:color-mix(in oklab, var(--color-slate-400) 60%, transparent);-webkit-text-decoration-color:color-mix(in oklab, var(--color-slate-400) 60%, transparent);text-decoration-color:color-mix(in oklab, var(--color-slate-400) 60%, transparent)}}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.\[color-scheme\:light\]{color-scheme:light}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-4{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-blue-200{--tw-ring-color:var(--color-blue-200)}.ring-stone-200{--tw-ring-color:var(--color-stone-200)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}@media (hover:hover){.group-hover\:border-factorize-500:is(:where(.group):hover *){border-color:var(--color-factorize-500)}.group-hover\:text-slate-950:is(:where(.group):hover *){color:var(--color-slate-950)}}.backdrop\:bg-slate-950\/50::backdrop{background-color:#15141080}@supports (color:color-mix(in lab, red, red)){.backdrop\:bg-slate-950\/50::backdrop{background-color:color-mix(in oklab, var(--color-slate-950) 50%, transparent)}}.backdrop\:bg-slate-950\/60::backdrop{background-color:#15141099}@supports (color:color-mix(in lab, red, red)){.backdrop\:bg-slate-950\/60::backdrop{background-color:color-mix(in oklab, var(--color-slate-950) 60%, transparent)}}@media (hover:hover){.hover\:-translate-y-0\.5:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.hover\:border-stone-300:hover{border-color:var(--color-stone-300)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-slate-800:hover{background-color:var(--color-slate-800)}.hover\:bg-stone-50:hover{background-color:var(--color-stone-50)}.hover\:bg-stone-100:hover{background-color:var(--color-stone-100)}.hover\:text-factorize-700:hover{color:var(--color-factorize-700)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-slate-500:hover{color:var(--color-slate-500)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-950:hover{color:var(--color-slate-950)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:ring-factorize-600:focus{--tw-ring-color:var(--color-factorize-600)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-factorize-500:focus-visible{--tw-ring-color:var(--color-factorize-500)}.focus-visible\:ring-factorize-600:focus-visible{--tw-ring-color:var(--color-factorize-600)}.focus-visible\:ring-red-500:focus-visible{--tw-ring-color:var(--color-red-500)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-inset:focus-visible{--tw-ring-inset:inset}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.aria-\[current\=page\]\:bg-stone-100[aria-current=page]{background-color:var(--color-stone-100)}.aria-\[current\=page\]\:font-semibold[aria-current=page]{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.aria-\[current\=page\]\:text-slate-950[aria-current=page]{color:var(--color-slate-950)}.aria-\[current\=page\]\:underline[aria-current=page]{text-decoration-line:underline}.aria-\[current\=page\]\:underline-offset-8[aria-current=page]{text-underline-offset:8px}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:mt-2{margin-top:calc(var(--spacing) * 2)}.sm\:mt-5{margin-top:calc(var(--spacing) * 5)}.sm\:mt-8{margin-top:calc(var(--spacing) * 8)}.sm\:inline{display:inline}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-\[8rem_minmax\(0\,1fr\)\]{grid-template-columns:8rem minmax(0,1fr)}.sm\:grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:p-10{padding:calc(var(--spacing) * 10)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:py-3{padding-block:calc(var(--spacing) * 3)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:py-28{padding-block:calc(var(--spacing) * 28)}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-7xl{font-size:var(--text-7xl);line-height:var(--tw-leading,var(--text-7xl--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:48rem){.md\:mb-1{margin-bottom:var(--spacing)}.md\:block{display:block}.md\:grid-cols-\[13rem_minmax\(0\,1fr\)\]{grid-template-columns:13rem minmax(0,1fr)}.md\:gap-10{gap:calc(var(--spacing) * 10)}.md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.md\:pb-0{padding-bottom:0}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}:where(.dark\:divide-slate-800:where(.dark,.dark *)>:not(:last-child)){border-color:var(--color-slate-800)}.dark\:border-amber-700:where(.dark,.dark *){border-color:var(--color-amber-700)}.dark\:border-amber-800:where(.dark,.dark *){border-color:var(--color-amber-800)}.dark\:border-red-900:where(.dark,.dark *){border-color:var(--color-red-900)}.dark\:border-slate-700:where(.dark,.dark *){border-color:var(--color-slate-700)}.dark\:border-slate-800:where(.dark,.dark *){border-color:var(--color-slate-800)}.dark\:bg-amber-950:where(.dark,.dark *){background-color:var(--color-amber-950)}.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:#4619014d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 30%, transparent)}}.dark\:bg-blue-950:where(.dark,.dark *){background-color:var(--color-blue-950)}.dark\:bg-factorize-500\/20:where(.dark,.dark *){background-color:#fdc90133}@supports (color:color-mix(in lab, red, red)){.dark\:bg-factorize-500\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-factorize-500) 20%, transparent)}}.dark\:bg-factorize-500\/50:where(.dark,.dark *){background-color:#fdc90180}@supports (color:color-mix(in lab, red, red)){.dark\:bg-factorize-500\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-factorize-500) 50%, transparent)}}.dark\:bg-green-950:where(.dark,.dark *){background-color:var(--color-green-950)}.dark\:bg-orange-950:where(.dark,.dark *){background-color:var(--color-orange-950)}.dark\:bg-red-950:where(.dark,.dark *){background-color:var(--color-red-950)}.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 40%, transparent)}}.dark\:bg-slate-600:where(.dark,.dark *){background-color:var(--color-slate-600)}.dark\:bg-slate-700:where(.dark,.dark *){background-color:var(--color-slate-700)}.dark\:bg-slate-800:where(.dark,.dark *){background-color:var(--color-slate-800)}.dark\:bg-slate-900:where(.dark,.dark *){background-color:var(--color-slate-900)}.dark\:bg-slate-950:where(.dark,.dark *){background-color:var(--color-slate-950)}.dark\:bg-white:where(.dark,.dark *){background-color:var(--color-white)}.dark\:text-amber-100:where(.dark,.dark *){color:var(--color-amber-100)}.dark\:text-amber-200:where(.dark,.dark *){color:var(--color-amber-200)}.dark\:text-amber-300:where(.dark,.dark *){color:var(--color-amber-300)}.dark\:text-blue-200:where(.dark,.dark *){color:var(--color-blue-200)}.dark\:text-factorize-100:where(.dark,.dark *){color:var(--color-factorize-100)}.dark\:text-green-200:where(.dark,.dark *){color:var(--color-green-200)}.dark\:text-green-300:where(.dark,.dark *){color:var(--color-green-300)}.dark\:text-orange-200:where(.dark,.dark *){color:var(--color-orange-200)}.dark\:text-red-200:where(.dark,.dark *){color:var(--color-red-200)}.dark\:text-red-300:where(.dark,.dark *){color:var(--color-red-300)}.dark\:text-slate-100:where(.dark,.dark *){color:var(--color-slate-100)}.dark\:text-slate-200:where(.dark,.dark *){color:var(--color-slate-200)}.dark\:text-slate-300:where(.dark,.dark *){color:var(--color-slate-300)}.dark\:text-slate-400:where(.dark,.dark *){color:var(--color-slate-400)}.dark\:text-slate-500:where(.dark,.dark *){color:var(--color-slate-500)}.dark\:text-slate-950:where(.dark,.dark *){color:var(--color-slate-950)}.dark\:text-white:where(.dark,.dark *){color:var(--color-white)}.dark\:decoration-slate-500\/60:where(.dark,.dark *){text-decoration-color:#71695e99}@supports (color:color-mix(in lab, red, red)){.dark\:decoration-slate-500\/60:where(.dark,.dark *){-webkit-text-decoration-color:color-mix(in oklab, var(--color-slate-500) 60%, transparent);-webkit-text-decoration-color:color-mix(in oklab, var(--color-slate-500) 60%, transparent);text-decoration-color:color-mix(in oklab, var(--color-slate-500) 60%, transparent)}}.dark\:\[color-scheme\:dark\]:where(.dark,.dark *){color-scheme:dark}.dark\:ring-blue-900:where(.dark,.dark *){--tw-ring-color:var(--color-blue-900)}.dark\:ring-slate-800:where(.dark,.dark *){--tw-ring-color:var(--color-slate-800)}@media (hover:hover){.dark\:hover\:border-slate-700:where(.dark,.dark *):hover{border-color:var(--color-slate-700)}.dark\:hover\:bg-red-950\/50:where(.dark,.dark *):hover{background-color:#46080980}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-950\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-950) 50%, transparent)}}.dark\:hover\:bg-slate-200:where(.dark,.dark *):hover{background-color:var(--color-slate-200)}.dark\:hover\:bg-slate-800:where(.dark,.dark *):hover{background-color:var(--color-slate-800)}.dark\:hover\:text-red-300:where(.dark,.dark *):hover{color:var(--color-red-300)}.dark\:hover\:text-slate-200:where(.dark,.dark *):hover{color:var(--color-slate-200)}.dark\:hover\:text-white:where(.dark,.dark *):hover{color:var(--color-white)}}.dark\:focus-visible\:ring-offset-slate-900:where(.dark,.dark *):focus-visible{--tw-ring-offset-color:var(--color-slate-900)}.dark\:focus-visible\:ring-offset-slate-950:where(.dark,.dark *):focus-visible{--tw-ring-offset-color:var(--color-slate-950)}.dark\:aria-\[current\=page\]\:bg-slate-800:where(.dark,.dark *)[aria-current=page]{background-color:var(--color-slate-800)}.dark\:aria-\[current\=page\]\:text-white:where(.dark,.dark *)[aria-current=page]{color:var(--color-white)}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none}.\[\&\>a\]\:w-full>a,.\[\&\>button\]\:w-full>button{width:100%}#job-detail>div.mt-6>div:last-child>:is(button,a){border-radius:.5rem;min-height:2.5rem;padding:.5rem .75rem}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-800:oklch(47% .157 37.304);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-950:oklch(26.6% .065 152.934);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-slate-100:#f5f1e8;--color-slate-200:#e7e1d4;--color-slate-300:#cec5b4;--color-slate-400:#9b9282;--color-slate-500:#71695e;--color-slate-600:#575047;--color-slate-700:#403b34;--color-slate-800:#2c2924;--color-slate-900:#201e1a;--color-slate-950:#151410;--color-stone-50:#fcfbf6;--color-stone-100:#f6f3e9;--color-stone-200:#e6e0d1;--color-stone-300:#d4cbb9;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-factorize-50:#fff9e6;--color-factorize-100:#fff0b8;--color-factorize-500:#fdc901;--color-factorize-600:#896300;--color-factorize-700:#705000}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{scrollbar-gutter:stable}body>main{flex:1;width:100%;min-width:0}body>footer{padding-bottom:calc(1.5rem + env(safe-area-inset-bottom))}}@layer components{.field{margin-top:calc(var(--spacing) * 1.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-slate-300);background-color:var(--color-white);width:100%;min-width:0;max-width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);color:var(--color-slate-950);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-outline-style:none;outline-style:none}.field::placeholder{color:var(--color-slate-400)}.field:focus{border-color:var(--color-factorize-500);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:#fdc90126}@supports (color:color-mix(in lab, red, red)){.field:focus{--tw-ring-color:color-mix(in oklab, var(--color-factorize-500) 15%, transparent)}}.field:where(.dark,.dark *){border-color:var(--color-slate-700);background-color:var(--color-slate-950);color:var(--color-white)}.field:where(.dark,.dark *)::placeholder{color:var(--color-slate-500)}select.field:not([multiple]){appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%23575047' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5'%3E%3Cpath d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .75rem center;background-repeat:no-repeat;background-size:1.25rem 1.25rem;padding-right:2.75rem}.dark select.field:not([multiple]){background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%23cec5b4' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5'%3E%3Cpath d='m6 8 4 4 4-4'/%3E%3C/svg%3E")}.label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--color-slate-700);display:block}.label:where(.dark,.dark *){color:var(--color-slate-300)}.template-editor{min-width:0;max-width:100%;position:relative}.template-editor textarea,.template-editor pre{scrollbar-gutter:stable;white-space:pre-wrap;overflow-wrap:break-word;min-height:11rem;font-family:var(--font-mono);font-size:var(--text-sm);tab-size:4;margin:0;padding:.625rem .75rem;line-height:1.25rem;overflow:auto}.template-editor pre{pointer-events:none;color:var(--color-slate-950);border:1px solid #0000;position:absolute;inset:0}.template-editor textarea{resize:vertical;color:#0000;caret-color:var(--color-slate-950);-webkit-text-fill-color:transparent;background-color:#0000;position:relative}.template-editor textarea::placeholder{color:var(--color-slate-400);-webkit-text-fill-color:var(--color-slate-400)}.template-delimiter{color:var(--color-factorize-600);font-weight:700}.template-expression{color:#2563eb}.dark .template-editor pre{color:var(--color-slate-100)}.dark .template-editor textarea{caret-color:var(--color-slate-100)}.dark .template-delimiter{color:var(--color-factorize-500)}.dark .template-expression{color:#93c5fd}.navbar-bee{transition:filter .15s}.dark .navbar-bee{filter:invert()}.landing-honeycomb{background-color:var(--color-stone-50);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='720' height='520' viewBox='0 0 720 520'%3E%3Cg transform='rotate(-7 360 260)' fill='none' stroke='%23705000' stroke-opacity='.07' stroke-width='1.5'%3E%3Cpath d='M25 140 87.5 31.75h125L275 140l-62.5 108.25h-125zM385 380l62.5-108.25h125L635 380l-62.5 108.25h-125z'/%3E%3C/g%3E%3C/svg%3E");background-position:top;background-size:720px 520px}.dark .landing-honeycomb{background-color:var(--color-slate-950);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='720' height='520' viewBox='0 0 720 520'%3E%3Cg transform='rotate(-7 360 260)' fill='none' stroke='%23fdc901' stroke-opacity='.05' stroke-width='1.5'%3E%3Cpath d='M25 140 87.5 31.75h125L275 140l-62.5 108.25h-125zM385 380l62.5-108.25h125L635 380l-62.5 108.25h-125z'/%3E%3C/g%3E%3C/svg%3E")}dialog[id$=-setup]{max-height:calc(100dvh - 2rem);margin:auto;overflow-y:auto}#trigger-dialog{width:min(44rem,100vw - 2rem);max-width:calc(100vw - 2rem)}#trigger-dialog>form,#trigger-dialog>form>div{min-width:0;max-width:100%}#trigger-rows td{overflow-wrap:anywhere}@media (min-width:40rem){div:has(>label>select[name=agentKind]){grid-template-columns:max-content minmax(0,1fr)}div:has(>label>select[name=agentKind])>label:first-child{width:11rem}}.trace-list{--trace-surface:#f6f3e9;--trace-border:#e6e0d1;--trace-muted:#71695e;--trace-accent:#705000;--trace-code:#fcfbf6;color:#201e1a}.dark .trace-list{--trace-surface:#2c2924;--trace-border:#403b34;--trace-muted:#b2a897;--trace-accent:#f4d36c;--trace-code:#151410;color:#f5f1e8}.trace-list>*+*{margin-top:1rem}.trace-message{min-width:0;padding:1rem 0}.trace-message h3{color:var(--trace-accent);letter-spacing:.08em;text-transform:uppercase;margin-bottom:.65rem;font-size:.7rem;font-weight:700}.trace-user_message{border-left:3px solid var(--trace-accent);background:var(--trace-surface);border-radius:0 .6rem .6rem 0;padding:1rem 1.25rem}.trace-prose{overflow-wrap:anywhere;font-size:.875rem;line-height:1.75}.trace-line{white-space:pre-wrap}.trace-prose h4{margin:.75rem 0 .25rem;font-weight:700}.trace-prose code{background:var(--trace-surface);border-radius:.25rem;padding:.1rem .3rem;font-size:.85em}.trace-bullet{padding-left:1.25rem;position:relative}.trace-bullet:before{content:"•";color:var(--trace-muted);position:absolute;left:.25rem}.trace-event,.trace-structured{border:1px solid var(--trace-border);border-radius:.6rem;min-width:0;overflow:hidden}.trace-event summary,.trace-structured summary{cursor:pointer;overflow-wrap:anywhere;padding:.75rem 1rem;font-size:.8rem}.trace-event summary:focus-visible,.trace-structured summary:focus-visible{outline:2px solid var(--trace-accent);outline-offset:-2px}.trace-event summary{background:var(--trace-surface)}.trace-event-kind{color:var(--trace-muted);text-transform:uppercase;letter-spacing:.04em;margin-right:.75rem;font-size:.65rem;display:inline-block}.trace-meta{border-style:dashed}.trace-error{border-color:#dc2626}.trace-warning{border-color:#b7791f}.trace-structured{margin:.75rem 0}.trace-structured>div{padding:0 1rem 1rem}.trace-code{background:var(--trace-code);min-width:0}.trace-prose .trace-code{border:1px solid var(--trace-border);border-radius:.5rem;margin:.75rem 0;overflow:hidden}.trace-code-label{border-bottom:1px solid var(--trace-border);color:var(--trace-muted);font:600 .65rem var(--font-mono);padding:.4rem 1rem}.trace-code pre{white-space:pre;max-height:24rem;font:.75rem/1.7 var(--font-mono);tab-size:2;margin:0;padding:1rem;overflow:auto}.trace-code code{font-size:inherit;background:0 0;padding:0}.trace-token-keyword{color:#7e22ce}.trace-token-string{color:#166534}.trace-token-comment{color:#71695e;font-style:italic}.trace-token-number,.trace-token-variable{color:#075985}.dark .trace-token-keyword{color:#d8b4fe}.dark .trace-token-string{color:#86efac}.dark .trace-token-comment{color:#b2a897}.dark .trace-token-number,.dark .trace-token-variable{color:#7dd3fc}.trace-thinking{color:var(--trace-muted);align-items:center;gap:.5rem;padding:.25rem 0;font-size:.8rem;display:flex}.trace-spinner{border:2px solid var(--trace-border);border-top-color:var(--trace-accent);border-radius:50%;width:.8rem;height:.8rem}.trace-thinking-live{display:none}.trace-running .trace-at-end>.trace-thinking:last-child .trace-spinner{animation:1s linear infinite trace-spin}.trace-running .trace-at-end>.trace-thinking:last-child .trace-thinking-live{display:inline}.trace-running .trace-at-end>.trace-thinking:last-child .trace-thinking-past{display:none}@keyframes trace-spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.trace-running .trace-at-end>.trace-thinking:last-child .trace-spinner{animation:none}}}@layer utilities{.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-y-0{inset-block:0}.top-2\.5{top:calc(var(--spacing) * 2.5)}.top-5{top:calc(var(--spacing) * 5)}.right-0{right:0}.right-2\.5{right:calc(var(--spacing) * 2.5)}.right-3{right:calc(var(--spacing) * 3)}.right-5{right:calc(var(--spacing) * 5)}.z-20{z-index:20}.m-auto{margin:auto}.mx-auto{margin-inline:auto}.mt-0{margin-top:0}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-9{margin-top:calc(var(--spacing) * 9)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-24{margin-top:calc(var(--spacing) * 24)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-16{height:calc(var(--spacing) * 16)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[70vh\]{max-height:70vh}.max-h-\[min\(60vh\,32rem\)\]{max-height:min(60vh,32rem)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-12{min-height:calc(var(--spacing) * 12)}.min-h-24{min-height:calc(var(--spacing) * 24)}.min-h-32{min-height:calc(var(--spacing) * 32)}.min-h-36{min-height:calc(var(--spacing) * 36)}.min-h-44{min-height:calc(var(--spacing) * 44)}.min-h-\[100dvh\]{min-height:100dvh}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-56{width:calc(var(--spacing) * 56)}.w-\[min\(32rem\,calc\(100vw-4rem\)\)\]{width:min(32rem,100vw - 4rem)}.w-\[min\(34rem\,calc\(100\%-2rem\)\)\]{width:min(34rem,100% - 2rem)}.w-\[min\(42rem\,calc\(100\%-2rem\)\)\]{width:min(42rem,100% - 2rem)}.w-\[min\(42rem\,calc\(100vw-2rem\)\)\]{width:min(42rem,100vw - 2rem)}.w-\[min\(44rem\,calc\(100\%-2rem\)\)\]{width:min(44rem,100% - 2rem)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-36{min-width:calc(var(--spacing) * 36)}.min-w-\[48rem\]{min-width:48rem}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-stone-200>:not(:last-child)){border-color:var(--color-stone-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-300{border-color:var(--color-amber-300)}.border-amber-400{border-color:var(--color-amber-400)}.border-red-200{border-color:var(--color-red-200)}.border-stone-200{border-color:var(--color-stone-200)}.border-stone-300{border-color:var(--color-stone-300)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-factorize-50{background-color:var(--color-factorize-50)}.bg-factorize-100{background-color:var(--color-factorize-100)}.bg-factorize-500{background-color:var(--color-factorize-500)}.bg-green-100{background-color:var(--color-green-100)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-slate-950{background-color:var(--color-slate-950)}.bg-stone-50{background-color:var(--color-stone-50)}.bg-stone-100{background-color:var(--color-stone-100)}.bg-stone-200{background-color:var(--color-stone-200)}.bg-stone-300{background-color:var(--color-stone-300)}.bg-white{background-color:var(--color-white)}.object-contain{object-fit:contain}.p-0{padding:0}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-1{padding-top:var(--spacing)}.pr-28{padding-right:calc(var(--spacing) * 28)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-8{--tw-leading:calc(var(--spacing) * 8);line-height:calc(var(--spacing) * 8)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-amber-950{color:var(--color-amber-950)}.text-blue-800{color:var(--color-blue-800)}.text-factorize-600{color:var(--color-factorize-600)}.text-factorize-700{color:var(--color-factorize-700)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-orange-800{color:var(--color-orange-800)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-slate-100{color:var(--color-slate-100)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-950{color:var(--color-slate-950)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline{text-decoration-line:underline}.decoration-slate-400\/60{text-decoration-color:#9b928299}@supports (color:color-mix(in lab, red, red)){.decoration-slate-400\/60{-webkit-text-decoration-color:color-mix(in oklab, var(--color-slate-400) 60%, transparent);-webkit-text-decoration-color:color-mix(in oklab, var(--color-slate-400) 60%, transparent);text-decoration-color:color-mix(in oklab, var(--color-slate-400) 60%, transparent)}}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.\[color-scheme\:light\]{color-scheme:light}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-4{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-blue-200{--tw-ring-color:var(--color-blue-200)}.ring-stone-200{--tw-ring-color:var(--color-stone-200)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}@media (hover:hover){.group-hover\:border-factorize-500:is(:where(.group):hover *){border-color:var(--color-factorize-500)}.group-hover\:text-slate-950:is(:where(.group):hover *){color:var(--color-slate-950)}}.backdrop\:bg-slate-950\/50::backdrop{background-color:#15141080}@supports (color:color-mix(in lab, red, red)){.backdrop\:bg-slate-950\/50::backdrop{background-color:color-mix(in oklab, var(--color-slate-950) 50%, transparent)}}.backdrop\:bg-slate-950\/60::backdrop{background-color:#15141099}@supports (color:color-mix(in lab, red, red)){.backdrop\:bg-slate-950\/60::backdrop{background-color:color-mix(in oklab, var(--color-slate-950) 60%, transparent)}}@media (hover:hover){.hover\:-translate-y-0\.5:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.hover\:border-stone-300:hover{border-color:var(--color-stone-300)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-slate-800:hover{background-color:var(--color-slate-800)}.hover\:bg-stone-50:hover{background-color:var(--color-stone-50)}.hover\:bg-stone-100:hover{background-color:var(--color-stone-100)}.hover\:text-factorize-700:hover{color:var(--color-factorize-700)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-slate-500:hover{color:var(--color-slate-500)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-950:hover{color:var(--color-slate-950)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:ring-factorize-600:focus{--tw-ring-color:var(--color-factorize-600)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-factorize-500:focus-visible{--tw-ring-color:var(--color-factorize-500)}.focus-visible\:ring-factorize-600:focus-visible{--tw-ring-color:var(--color-factorize-600)}.focus-visible\:ring-red-500:focus-visible{--tw-ring-color:var(--color-red-500)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-inset:focus-visible{--tw-ring-inset:inset}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.aria-\[current\=page\]\:bg-stone-100[aria-current=page]{background-color:var(--color-stone-100)}.aria-\[current\=page\]\:font-semibold[aria-current=page]{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.aria-\[current\=page\]\:text-slate-950[aria-current=page]{color:var(--color-slate-950)}.aria-\[current\=page\]\:underline[aria-current=page]{text-decoration-line:underline}.aria-\[current\=page\]\:underline-offset-8[aria-current=page]{text-underline-offset:8px}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:mt-2{margin-top:calc(var(--spacing) * 2)}.sm\:mt-5{margin-top:calc(var(--spacing) * 5)}.sm\:mt-8{margin-top:calc(var(--spacing) * 8)}.sm\:inline{display:inline}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-\[8rem_minmax\(0\,1fr\)\]{grid-template-columns:8rem minmax(0,1fr)}.sm\:grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:p-10{padding:calc(var(--spacing) * 10)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:py-3{padding-block:calc(var(--spacing) * 3)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:py-28{padding-block:calc(var(--spacing) * 28)}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-7xl{font-size:var(--text-7xl);line-height:var(--tw-leading,var(--text-7xl--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:48rem){.md\:mb-1{margin-bottom:var(--spacing)}.md\:block{display:block}.md\:grid-cols-\[13rem_minmax\(0\,1fr\)\]{grid-template-columns:13rem minmax(0,1fr)}.md\:gap-10{gap:calc(var(--spacing) * 10)}.md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.md\:pb-0{padding-bottom:0}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}:where(.dark\:divide-slate-800:where(.dark,.dark *)>:not(:last-child)){border-color:var(--color-slate-800)}.dark\:border-amber-700:where(.dark,.dark *){border-color:var(--color-amber-700)}.dark\:border-amber-800:where(.dark,.dark *){border-color:var(--color-amber-800)}.dark\:border-red-900:where(.dark,.dark *){border-color:var(--color-red-900)}.dark\:border-slate-700:where(.dark,.dark *){border-color:var(--color-slate-700)}.dark\:border-slate-800:where(.dark,.dark *){border-color:var(--color-slate-800)}.dark\:bg-amber-950:where(.dark,.dark *){background-color:var(--color-amber-950)}.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:#4619014d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 30%, transparent)}}.dark\:bg-blue-950:where(.dark,.dark *){background-color:var(--color-blue-950)}.dark\:bg-factorize-500\/20:where(.dark,.dark *){background-color:#fdc90133}@supports (color:color-mix(in lab, red, red)){.dark\:bg-factorize-500\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-factorize-500) 20%, transparent)}}.dark\:bg-factorize-500\/50:where(.dark,.dark *){background-color:#fdc90180}@supports (color:color-mix(in lab, red, red)){.dark\:bg-factorize-500\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-factorize-500) 50%, transparent)}}.dark\:bg-green-950:where(.dark,.dark *){background-color:var(--color-green-950)}.dark\:bg-orange-950:where(.dark,.dark *){background-color:var(--color-orange-950)}.dark\:bg-red-950:where(.dark,.dark *){background-color:var(--color-red-950)}.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 40%, transparent)}}.dark\:bg-slate-600:where(.dark,.dark *){background-color:var(--color-slate-600)}.dark\:bg-slate-700:where(.dark,.dark *){background-color:var(--color-slate-700)}.dark\:bg-slate-800:where(.dark,.dark *){background-color:var(--color-slate-800)}.dark\:bg-slate-900:where(.dark,.dark *){background-color:var(--color-slate-900)}.dark\:bg-slate-950:where(.dark,.dark *){background-color:var(--color-slate-950)}.dark\:bg-white:where(.dark,.dark *){background-color:var(--color-white)}.dark\:text-amber-100:where(.dark,.dark *){color:var(--color-amber-100)}.dark\:text-amber-200:where(.dark,.dark *){color:var(--color-amber-200)}.dark\:text-amber-300:where(.dark,.dark *){color:var(--color-amber-300)}.dark\:text-blue-200:where(.dark,.dark *){color:var(--color-blue-200)}.dark\:text-factorize-100:where(.dark,.dark *){color:var(--color-factorize-100)}.dark\:text-green-200:where(.dark,.dark *){color:var(--color-green-200)}.dark\:text-green-300:where(.dark,.dark *){color:var(--color-green-300)}.dark\:text-orange-200:where(.dark,.dark *){color:var(--color-orange-200)}.dark\:text-red-200:where(.dark,.dark *){color:var(--color-red-200)}.dark\:text-red-300:where(.dark,.dark *){color:var(--color-red-300)}.dark\:text-slate-100:where(.dark,.dark *){color:var(--color-slate-100)}.dark\:text-slate-200:where(.dark,.dark *){color:var(--color-slate-200)}.dark\:text-slate-300:where(.dark,.dark *){color:var(--color-slate-300)}.dark\:text-slate-400:where(.dark,.dark *){color:var(--color-slate-400)}.dark\:text-slate-500:where(.dark,.dark *){color:var(--color-slate-500)}.dark\:text-slate-950:where(.dark,.dark *){color:var(--color-slate-950)}.dark\:text-white:where(.dark,.dark *){color:var(--color-white)}.dark\:decoration-slate-500\/60:where(.dark,.dark *){text-decoration-color:#71695e99}@supports (color:color-mix(in lab, red, red)){.dark\:decoration-slate-500\/60:where(.dark,.dark *){-webkit-text-decoration-color:color-mix(in oklab, var(--color-slate-500) 60%, transparent);-webkit-text-decoration-color:color-mix(in oklab, var(--color-slate-500) 60%, transparent);text-decoration-color:color-mix(in oklab, var(--color-slate-500) 60%, transparent)}}.dark\:\[color-scheme\:dark\]:where(.dark,.dark *){color-scheme:dark}.dark\:ring-blue-900:where(.dark,.dark *){--tw-ring-color:var(--color-blue-900)}.dark\:ring-slate-800:where(.dark,.dark *){--tw-ring-color:var(--color-slate-800)}@media (hover:hover){.dark\:hover\:border-slate-700:where(.dark,.dark *):hover{border-color:var(--color-slate-700)}.dark\:hover\:bg-red-950\/50:where(.dark,.dark *):hover{background-color:#46080980}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-950\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-950) 50%, transparent)}}.dark\:hover\:bg-slate-200:where(.dark,.dark *):hover{background-color:var(--color-slate-200)}.dark\:hover\:bg-slate-800:where(.dark,.dark *):hover{background-color:var(--color-slate-800)}.dark\:hover\:text-red-300:where(.dark,.dark *):hover{color:var(--color-red-300)}.dark\:hover\:text-slate-200:where(.dark,.dark *):hover{color:var(--color-slate-200)}.dark\:hover\:text-white:where(.dark,.dark *):hover{color:var(--color-white)}}.dark\:focus-visible\:ring-offset-slate-900:where(.dark,.dark *):focus-visible{--tw-ring-offset-color:var(--color-slate-900)}.dark\:focus-visible\:ring-offset-slate-950:where(.dark,.dark *):focus-visible{--tw-ring-offset-color:var(--color-slate-950)}.dark\:aria-\[current\=page\]\:bg-slate-800:where(.dark,.dark *)[aria-current=page]{background-color:var(--color-slate-800)}.dark\:aria-\[current\=page\]\:text-white:where(.dark,.dark *)[aria-current=page]{color:var(--color-white)}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none}.\[\&\>a\]\:w-full>a,.\[\&\>button\]\:w-full>button{width:100%}#job-detail>div.mt-6>div:last-child>:is(button,a){border-radius:.5rem;min-height:2.5rem;padding:.5rem .75rem}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0} \ No newline at end of file diff --git a/packages/app/scripts/validate-auth-email.check.mjs b/packages/app/scripts/validate-auth-email.check.mjs new file mode 100644 index 0000000..ab38db7 --- /dev/null +++ b/packages/app/scripts/validate-auth-email.check.mjs @@ -0,0 +1,21 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { validatePostmark } from "./validate-auth-email.mjs"; +const config = { APP_ORIGIN: "https://app.example.test", POSTMARK_SERVER_TOKEN: "test", POSTMARK_FROM_EMAIL: "accounts@example.test", POSTMARK_MESSAGE_STREAM: "outbound" }; +test("Postmark preflight fails closed for missing settings, wrong stream and unverified sender", async () => { + for (const field of Object.keys(config)) await assert.rejects(validatePostmark({ ...config, [field]: "" }), /Configure/); + await assert.rejects(validatePostmark({ ...config, APP_ORIGIN: "http://app.example.test" }), /HTTPS/); + await assert.rejects(validatePostmark(config, async () => Response.json({ MessageStreamType: "Broadcasts" })), /transactional/); + await assert.rejects(validatePostmark(config, async () => Response.json({ MessageStreamType: "Transactional", ArchivedAt: "2026-01-01" })), /transactional/); + await assert.rejects(validatePostmark(config, async url => url.endsWith("/email") ? Response.json({ ErrorCode: 400 }, { status: 422 }) : Response.json({ MessageStreamType: "Transactional" })), /sender/); +}); +test("Postmark preflight uses the configured stream and validates an actual send to the sender", async () => { + let sent = false; + await validatePostmark(config, async (url, init) => { + assert.equal(init.headers["X-Postmark-Server-Token"], config.POSTMARK_SERVER_TOKEN); + if (!url.endsWith("/email")) { assert.ok(url.endsWith("/outbound")); return Response.json({ MessageStreamType: "Transactional", ArchivedAt: null }); } + const body = JSON.parse(init.body); assert.equal(body.From, config.POSTMARK_FROM_EMAIL); assert.equal(body.To, config.POSTMARK_FROM_EMAIL); assert.equal(body.MessageStream, "outbound"); sent = true; + return Response.json({ ErrorCode: 0 }); + }); + assert.equal(sent, true); +}); diff --git a/packages/app/scripts/validate-auth-email.mjs b/packages/app/scripts/validate-auth-email.mjs new file mode 100644 index 0000000..9490edf --- /dev/null +++ b/packages/app/scripts/validate-auth-email.mjs @@ -0,0 +1,25 @@ +import { pathToFileURL } from "node:url"; + +/** Validate deployment credentials without printing them. Sends one preflight email to the sender. */ +export async function validatePostmark(env, request = fetch) { + const { APP_ORIGIN, POSTMARK_SERVER_TOKEN, POSTMARK_FROM_EMAIL, POSTMARK_MESSAGE_STREAM } = env; + if (!APP_ORIGIN || !POSTMARK_SERVER_TOKEN?.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(POSTMARK_FROM_EMAIL ?? "") || !POSTMARK_MESSAGE_STREAM?.trim()) throw new Error("Configure APP_ORIGIN, POSTMARK_SERVER_TOKEN, POSTMARK_FROM_EMAIL and POSTMARK_MESSAGE_STREAM before deployment."); + const origin = new URL(APP_ORIGIN); + if (origin.protocol !== "https:" || origin.origin !== APP_ORIGIN) throw new Error("APP_ORIGIN must be an HTTPS origin without a trailing slash."); + const headers = { "X-Postmark-Server-Token": POSTMARK_SERVER_TOKEN, "Content-Type": "application/json", Accept: "application/json" }; + const streamResponse = await request(`https://api.postmarkapp.com/message-streams/${encodeURIComponent(POSTMARK_MESSAGE_STREAM)}`, { headers }); + const stream = await streamResponse.json(); + if (!streamResponse.ok || stream.MessageStreamType !== "Transactional" || stream.ArchivedAt) throw new Error("Postmark requires a valid server token and an active transactional message stream."); + const emailResponse = await request("https://api.postmarkapp.com/email", { method: "POST", headers, body: JSON.stringify({ + From: POSTMARK_FROM_EMAIL, To: POSTMARK_FROM_EMAIL, MessageStream: POSTMARK_MESSAGE_STREAM, + Subject: "Factorize authentication email deployment check", + TextBody: `Authentication email configuration for ${APP_ORIGIN} passed the deployment preflight.`, + }) }); + const email = await emailResponse.json(); + if (!emailResponse.ok || email.ErrorCode !== 0) throw new Error("Postmark rejected the preflight email. Confirm sender/domain verification and server sending permissions."); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { await validatePostmark(process.env); console.log("Postmark authentication email preflight passed."); } + catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/packages/app/src/auth-email.ts b/packages/app/src/auth-email.ts new file mode 100644 index 0000000..d4208cc --- /dev/null +++ b/packages/app/src/auth-email.ts @@ -0,0 +1,24 @@ +import { validEmail } from "./auth"; +import type { Env } from "./types"; + +export function validateAuthEmailConfig(env: Env): void { + const origin = new URL(env.APP_ORIGIN); + if (origin.protocol !== "https:" || origin.origin !== env.APP_ORIGIN || !env.POSTMARK_SERVER_TOKEN?.trim() || !validEmail(env.POSTMARK_FROM_EMAIL ?? "") || !env.POSTMARK_MESSAGE_STREAM?.trim()) { + throw new Error("Authentication email requires an HTTPS APP_ORIGIN, POSTMARK_SERVER_TOKEN, POSTMARK_FROM_EMAIL and POSTMARK_MESSAGE_STREAM."); + } +} + +export async function sendAuthEmail(env: Env, email: string, token: string, purpose: "verify" | "reset"): Promise { + validateAuthEmailConfig(env); + const url = new URL(purpose === "verify" ? "/auth/verify" : "/auth/password-reset", env.APP_ORIGIN); + url.searchParams.set("token", token); + const response = await fetch("https://api.postmarkapp.com/email", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Postmark-Server-Token": env.POSTMARK_SERVER_TOKEN! }, + body: JSON.stringify({ From: env.POSTMARK_FROM_EMAIL, To: email, MessageStream: env.POSTMARK_MESSAGE_STREAM, + Subject: purpose === "verify" ? "Verify your Factorize email" : "Reset your Factorize password", + TextBody: `${purpose === "verify" ? "Verify your email" : "Reset your password"}: ${url}\n\nThis link expires in one hour and can be used once. If you did not request this, ignore this email.` }), + }); + const result = await response.json() as { ErrorCode?: number }; + if (!response.ok || result.ErrorCode !== 0) throw new Error("Postmark could not deliver authentication email."); +} diff --git a/packages/app/src/auth.ts b/packages/app/src/auth.ts index 5c4de57..03b8f84 100644 --- a/packages/app/src/auth.ts +++ b/packages/app/src/auth.ts @@ -27,3 +27,6 @@ export async function verifyPassword(password: string, encoded: string): Promise } export async function tokenDigest(token: string, secret: string): Promise { return hmac(token, secret); } + +export const normalizeUsername = (value: string): string => value.trim().toLowerCase(); +export const validUsername = (value: string): boolean => /^[a-z0-9_][a-z0-9_-]{2,31}$/.test(value); diff --git a/packages/app/src/device-oauth.ts b/packages/app/src/device-oauth.ts index 75c2884..1c8653e 100644 --- a/packages/app/src/device-oauth.ts +++ b/packages/app/src/device-oauth.ts @@ -40,7 +40,7 @@ export function deviceLoginRedirect(appOrigin: string, returnTo: string): Respon status: 302, headers: { "Cache-Control": "no-store", - Location: `${appOrigin}/auth/linear`, + Location: `${appOrigin}/auth/login`, "Set-Cookie": `factorize_oauth_return=${encodeURIComponent(returnTo)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`, }, }); diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 3a146ae..ef8f21c 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -26,6 +26,12 @@ app.use("*", async (c, next) => { if (new URL(c.req.url).protocol === "https:") c.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); }); +app.use("/auth/*", async (c, next) => { + c.header("Cache-Control", "no-store"); + if (c.req.method === "POST" && (c.req.header("Origin") !== c.env.APP_ORIGIN || c.req.header("Sec-Fetch-Site") === "cross-site")) return c.text("Invalid request origin", 403); + await next(); +}); + app.onError((error, c) => { console.error(JSON.stringify({ event: "factorize_unexpected_failure", path: c.req.path, message: error.message, factorizeTailSuppressed: c.req.path.startsWith("/webhooks/cloudflare/") })); if (c.req.path.startsWith("/api/v1/")) return c.json({ error: { code: "internal_error", message: "Factorize could not complete this request. Try again shortly." } }, 502); @@ -54,9 +60,9 @@ async function authIdentity(c: any, path: string, input: Record const result = path === "/signup" ? await auth.signup(input) : path === "/login" ? await auth.login(input) : path === "/reset/request" ? await auth.requestReset(input) : await auth.completeReset(input); return { response: new Response(JSON.stringify(result.body), { status: result.status, headers: { "Content-Type": "application/json" } }), body: result.body as any }; } -async function establishSession(c: any, identity: { userId: string; email: string; tenantId: string }) { - const data = await new IdentityRepository(databaseFor(c.env), identity.tenantId).upsertOwner(identity.userId, identity.email); - if (data.role !== "owner") return false; +async function establishSession(c: any, identity: { userId: string; email: string; tenantId: string; sessionVersion: number }) { + const data = await new IdentityRepository(databaseFor(c.env), identity.tenantId).member(identity.userId); + if (data?.role !== "owner" || data.sessionVersion !== identity.sessionVersion) return false; const lifetime = 60 * 60 * 24 * 7; setCookie(c, "factorize_session", await signSession({ tenantId: identity.tenantId, userId: identity.userId, email: identity.email, exp: Math.floor(Date.now() / 1000) + lifetime, sessionVersion: data.sessionVersion }, c.env.SESSION_SIGNING_SECRET), { httpOnly: true, secure: new URL(c.env.APP_ORIGIN).protocol === "https:", sameSite: "Lax", path: "/", maxAge: lifetime }); return true; @@ -72,7 +78,8 @@ app.get("/styles.css", (c) => c.env.ASSETS.fetch(c.req.raw)); app.get("/healthz", (c) => c.json({ ok: true })); app.get("/auth/linear", async (c) => { - const state = crypto.randomUUID(); + const session = await owner(c); if (!session) return c.redirect("/auth/login"); + const state = await signSetupState({ tenantId: session.tenantId, userId: session.userId, nonce: crypto.randomUUID(), exp: Math.floor(Date.now() / 1000) + 600 }, c.env.SESSION_SIGNING_SECRET); setCookie(c, "linear_oauth_state", state, { httpOnly: true, secure: new URL(c.env.APP_ORIGIN).protocol === "https:", sameSite: "Lax", path: "/auth/linear", maxAge: 600 }); const redirect = new URL("https://linear.app/oauth/authorize"); redirect.searchParams.set("client_id", c.env.LINEAR_CLIENT_ID); @@ -84,8 +91,10 @@ app.get("/auth/linear", async (c) => { }); app.get("/auth/linear/callback", async (c) => { - const code = c.req.query("code"); - if (!code || c.req.query("state") !== getCookie(c, "linear_oauth_state")) return c.text("Invalid OAuth state", 400); + const session = await owner(c), code = c.req.query("code"), rawState = c.req.query("state"); + const state = rawState ? await readSetupState(rawState, c.env.SESSION_SIGNING_SECRET) : null; + if (!session || !code || !state || rawState !== getCookie(c, "linear_oauth_state") || state.tenantId !== session.tenantId || state.userId !== session.userId) return c.text("Invalid OAuth state", 400); + deleteCookie(c, "linear_oauth_state", { path: "/auth/linear" }); const tokenResponse = await fetch("https://api.linear.app/oauth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, redirect_uri: `${c.env.APP_ORIGIN}/auth/linear/callback`, client_id: c.env.LINEAR_CLIENT_ID, client_secret: c.env.LINEAR_CLIENT_SECRET, grant_type: "authorization_code" }) }); const tokens = await tokenResponse.json() as { access_token?: string; refresh_token?: string }; if (!tokenResponse.ok || !tokens.access_token || !tokens.refresh_token) return c.text("Linear token exchange failed", 502); @@ -94,28 +103,47 @@ app.get("/auth/linear/callback", async (c) => { const organization = me.data?.organization; const viewer = me.data?.viewer; if (!organization?.id || !viewer?.id) return c.text("Could not identify Linear workspace", 502); - const member = await new IdentityRepository(databaseFor(c.env), organization.id).upsertOwner(viewer.id, viewer.email, organization.name); - if (member.role !== "owner") return c.text("This Factorize tenant requires an owner invitation.", 403); - await new ConnectionRepository(databaseFor(c.env), organization.id, c.env.CREDENTIAL_ENCRYPTION_KEY).put("linear", { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, organizationId: organization.id, organizationName: organization.name, viewerId: viewer.id, viewerEmail: viewer.email }); - const lifetime = 60 * 60 * 24 * 7; - setCookie(c, "factorize_session", await signSession({ tenantId: organization.id, userId: viewer.id, email: viewer.email, exp: Math.floor(Date.now() / 1000) + lifetime, sessionVersion: member.sessionVersion }, c.env.SESSION_SIGNING_SECRET), { httpOnly: true, secure: new URL(c.env.APP_ORIGIN).protocol === "https:", sameSite: "Lax", path: "/", maxAge: lifetime }); - const oauthReturn = getCookie(c, "factorize_oauth_return"); - if (oauthReturn?.startsWith("/authorize?") || oauthReturn?.startsWith("/device")) { deleteCookie(c, "factorize_oauth_return", { path: "/" }); return c.redirect(oauthReturn); } - return c.redirect("/jobs"); + const connected = await new ConnectionRepository(databaseFor(c.env), session.tenantId, c.env.CREDENTIAL_ENCRYPTION_KEY).putLinear(organization.id, { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, organizationId: organization.id, organizationName: organization.name, viewerId: viewer.id, viewerEmail: viewer.email }); + if (!connected) return c.text("This Linear workspace is already connected to another Factorize workspace. Sign in to that Factorize account to manage it.", 409); + return c.redirect("/settings/integrations"); }); -app.post("/auth/logout", (c) => { +app.post("/auth/logout", async (c) => { + const session = await owner(c); + if (session) await new IdentityRepository(databaseFor(c.env), session.tenantId).revoke(session.userId); deleteCookie(c, "factorize_session", { path: "/" }); return c.redirect("/"); }); -app.post("/auth/signup", async (c) => { const input = await c.req.parseBody(); const result = await authIdentity(c, "/signup", input as Record); if (!result.response.ok) return c.json(result.body, result.response.status as any); if (!await establishSession(c, result.body)) return c.text("This account cannot access the tenant", 403); return c.redirect("/jobs", 303); }); -app.post("/auth/login", async (c) => { const input = await c.req.parseBody(); const result = await authIdentity(c, "/login", input as Record); if (!result.response.ok) return c.html(render(authPage("Sign in", "login", String(input.email ?? ""), result.body.error), c.get("cspNonce")), result.response.status as any); if (!await establishSession(c, result.body)) return c.text("This account cannot access the tenant", 403); return c.redirect("/jobs", 303); }); +app.post("/auth/signup", async (c) => { const input = await c.req.parseBody(); const result = await authIdentity(c, "/signup", input as Record); return c.html(render(authPage(result.response.ok ? "Check your email to verify your account" : "Create your Factorize account", result.response.ok ? "verify-request" : "signup", "", result.body.error), c.get("cspNonce")), result.response.status as any); }); +app.post("/auth/login", async (c) => { const input = await c.req.parseBody(); const result = await authIdentity(c, "/login", input as Record); if (!result.response.ok) return c.html(render(authPage("Sign in", "login", String(input.username ?? input.email ?? ""), result.body.error), c.get("cspNonce")), result.response.status as any); if (!await establishSession(c, result.body)) return c.text("This account cannot access the tenant", 403); const oauthReturn = getCookie(c, "factorize_oauth_return"); + if (oauthReturn?.startsWith("/authorize?") || oauthReturn?.startsWith("/device")) { deleteCookie(c, "factorize_oauth_return", { path: "/" }); return c.redirect(oauthReturn, 303); } + return c.redirect("/settings/integrations", 303); }); app.post("/auth/password-reset", async (c) => { const input = await c.req.parseBody(); const result = await authIdentity(c, "/reset/request", input as Record); return c.html(render(authPage("Check your email", "reset", "", result.body.error), c.get("cspNonce")), result.response.status as any); }); app.post("/auth/password-reset/complete", async (c) => { const input = await c.req.parseBody(); const result = await authIdentity(c, "/reset/complete", input as Record); return result.response.ok ? c.redirect("/auth/login", 303) : c.html(render(authPage("Reset password", "complete", String(input.token ?? ""), result.body.error), c.get("cspNonce")), result.response.status as any); }); +app.get("/auth/verify", (c) => c.html(render(authPage("Verify your email", "verify", c.req.query("token") ?? ""), c.get("cspNonce")))); +app.post("/auth/verify", async (c) => { + const input = await c.req.parseBody(); + const result = await new AuthRepository(databaseFor(c.env), c.env).completeVerification(String(input.token ?? "")); + return result.status === 200 ? c.redirect("/auth/login", 303) : c.html(render(authPage("Verify your email", "verify-request", "", "The verification link is invalid or expired. Request a new one."), c.get("cspNonce")), 400); +}); +app.post("/auth/verify/request", async (c) => { + const result = await new AuthRepository(databaseFor(c.env), c.env).requestEmail(await c.req.parseBody(), "verify"); + return c.html(render(authPage("Check your email", "verify-request"), c.get("cspNonce")), result.status as any); +}); +app.get("/auth/verify/request", (c) => c.html(render(authPage("Resend verification email", "verify-request"), c.get("cspNonce")))); +app.get("/settings/password", async (c) => await owner(c) ? c.html(render(authPage("Change password", "change"), c.get("cspNonce"))) : c.redirect("/auth/login")); +app.post("/auth/password-change", async (c) => { + const session = await owner(c); if (!session) return c.text("Unauthorized", 401); + const result = await new AuthRepository(databaseFor(c.env), c.env).changePassword(session.userId, await c.req.parseBody()); + if (result.status !== 200) return c.html(render(authPage("Change password", "change", "", "error" in result.body ? result.body.error : ""), c.get("cspNonce")), result.status as any); + deleteCookie(c, "factorize_session", { path: "/" }); + return c.redirect("/auth/login", 303); +}); + app.get("/auth/clickup", async (c) => { - const session = await owner(c); if (!session) return c.redirect("/auth/linear"); + const session = await owner(c); if (!session) return c.redirect("/auth/login"); if (!c.env.CLICKUP_CLIENT_ID) return c.text("ClickUp OAuth is not configured", 503); const state = await signSession({ ...session, exp: Math.floor(Date.now() / 1000) + 600 }, c.env.SESSION_SIGNING_SECRET); setCookie(c, "clickup_oauth_state", state, { httpOnly: true, secure: new URL(c.env.APP_ORIGIN).protocol === "https:", sameSite: "Lax", path: "/auth/clickup", maxAge: 600 }); @@ -144,7 +172,7 @@ app.get("/auth/clickup/callback", async (c) => { }); app.get("/auth/github/install", async (c) => { - const session = await owner(c); if (!session) return c.redirect("/auth/linear"); + const session = await owner(c); if (!session) return c.redirect("/auth/login"); if (!c.env.GITHUB_APP_SLUG) return c.text("GitHub App is not configured", 503); const nonce = crypto.randomUUID(); const state = await signSetupState({ tenantId: session.tenantId, userId: session.userId, nonce, exp: Math.floor(Date.now() / 1000) + 600 }, c.env.SESSION_SIGNING_SECRET); @@ -185,7 +213,9 @@ app.post("/webhooks/linear", async (c) => { if (typeof organizationId !== "string" || !organizationId) return c.text("Missing organization ID", 400); const deliveryId = c.req.header("linear-delivery"); if (!deliveryId) return c.text("Missing delivery ID", 400); - await new WebhookService(databaseFor(c.env), c.env, organizationId).linear(event, `linear:${deliveryId}`); return c.body(null, 200); + const binding = (await databaseFor(c.env).pool.query<{ tenant_id: string }>("SELECT w.tenant_id FROM app.linear_workspaces w JOIN app.connections c ON c.tenant_id=w.tenant_id AND c.kind='linear' WHERE w.organization_id=$1", [organizationId])).rows[0]; + if (binding) await new WebhookService(databaseFor(c.env), c.env, binding.tenant_id).linear(event, `linear:${deliveryId}`); + return c.body(null, 200); }); app.post("/webhooks/clickup/:tenantId", async (c) => { @@ -226,13 +256,13 @@ app.get("/", async (c) => { app.get("/auth/signup", (c) => c.html(render(authPage("Create your Factorize account", "signup"), c.get("cspNonce")))); app.get("/auth/login", (c) => c.html(render(authPage("Sign in to Factorize", "login"), c.get("cspNonce")))); app.get("/auth/password-reset", (c) => c.html(render(authPage("Reset your password", "reset"), c.get("cspNonce")))); -app.get("/jobs", async (c) => { const session = await owner(c); return session ? c.html(render(jobsPage({ email: session.email }), c.get("cspNonce"))) : c.redirect("/auth/linear"); }); -app.get("/jobs/new", async (c) => { const session = await owner(c); return session ? c.html(render(jobPage({ email: session.email }), c.get("cspNonce"))) : c.redirect("/auth/linear"); }); -app.get("/jobs/:id", async (c) => { const session = await owner(c); return session ? c.html(render(jobDetailPage({ email: session.email }, c.req.param("id")), c.get("cspNonce"))) : c.redirect("/auth/linear"); }); -app.get("/jobs/:id/edit", async (c) => { const session = await owner(c); return session ? c.html(render(jobPage({ email: session.email }, c.req.param("id")), c.get("cspNonce"))) : c.redirect("/auth/linear"); }); -app.get("/job-runs/:id", async (c) => { const session = await owner(c); return session ? c.html(render(jobRunPage({ email: session.email }, c.req.param("id")), c.get("cspNonce"))) : c.redirect("/auth/linear"); }); -app.get("/settings", async (c) => { const session = await owner(c); return session ? c.redirect("/settings/integrations") : c.redirect("/auth/linear"); }); -app.get("/settings/integrations", async (c) => { const session = await owner(c); return session ? c.html(render(settingsPage({ email: session.email }), c.get("cspNonce"))) : c.redirect("/auth/linear"); }); -app.get("/settings/api-keys", async (c) => { const session = await owner(c); return session ? c.html(render(apiKeysSettingsPage({ email: session.email }), c.get("cspNonce"))) : c.redirect("/auth/linear"); }); +app.get("/jobs", async (c) => { const session = await owner(c); return session ? c.html(render(jobsPage({ email: session.email }), c.get("cspNonce"))) : c.redirect("/auth/login"); }); +app.get("/jobs/new", async (c) => { const session = await owner(c); return session ? c.html(render(jobPage({ email: session.email }), c.get("cspNonce"))) : c.redirect("/auth/login"); }); +app.get("/jobs/:id", async (c) => { const session = await owner(c); return session ? c.html(render(jobDetailPage({ email: session.email }, c.req.param("id")), c.get("cspNonce"))) : c.redirect("/auth/login"); }); +app.get("/jobs/:id/edit", async (c) => { const session = await owner(c); return session ? c.html(render(jobPage({ email: session.email }, c.req.param("id")), c.get("cspNonce"))) : c.redirect("/auth/login"); }); +app.get("/job-runs/:id", async (c) => { const session = await owner(c); return session ? c.html(render(jobRunPage({ email: session.email }, c.req.param("id")), c.get("cspNonce"))) : c.redirect("/auth/login"); }); +app.get("/settings", async (c) => { const session = await owner(c); return session ? c.redirect("/settings/integrations") : c.redirect("/auth/login"); }); +app.get("/settings/integrations", async (c) => { const session = await owner(c); return session ? c.html(render(settingsPage({ email: session.email }), c.get("cspNonce"))) : c.redirect("/auth/login"); }); +app.get("/settings/api-keys", async (c) => { const session = await owner(c); return session ? c.html(render(apiKeysSettingsPage({ email: session.email }), c.get("cspNonce"))) : c.redirect("/auth/login"); }); export default app; diff --git a/packages/app/src/postgres/auth-repository.ts b/packages/app/src/postgres/auth-repository.ts index fece618..e8bfb6c 100644 --- a/packages/app/src/postgres/auth-repository.ts +++ b/packages/app/src/postgres/auth-repository.ts @@ -1,56 +1,122 @@ -import { hashPassword, normalizeEmail, tokenDigest, validEmail, validPassword, verifyPassword } from "../auth"; +import { hashPassword, normalizeEmail, normalizeUsername, tokenDigest, validEmail, validPassword, validUsername, verifyPassword } from "../auth"; +import { sendAuthEmail, validateAuthEmailConfig } from "../auth-email"; import type { Env } from "../types"; -import type { Database } from "./database"; +import type { Database, DatabaseClient } from "./database"; + +const ok = () => ({ status: 200, body: { ok: true } }); +const invalidLink = () => ({ status: 400, body: { error: "The link is invalid or expired." } }); export class AuthRepository { constructor(private database: Database, private env: Env) {} async signup(input: Record) { - const email = normalizeEmail(String(input.email ?? "")), password = String(input.password ?? ""); - if (!validEmail(email) || !validPassword(password)) return { status: 400, body: { error: "Use a valid email and a password of at least 12 characters." } }; - const userId = crypto.randomUUID(); + const email = normalizeEmail(String(input.email ?? "")), password = String(input.password ?? ""), username = normalizeUsername(String(input.username ?? "")); + if (!validEmail(email) || !validPassword(password) || !validUsername(username)) return { status: 400, body: { error: "Use a valid email, a 3–32 character username (letters, numbers, underscores or hyphens), and a password of 12–200 characters." } }; + validateAuthEmailConfig(this.env); + const userId = crypto.randomUUID(), passwordHash = await hashPassword(password); try { await this.database.transaction(async client => { - await client.query("INSERT INTO app.auth_users(id,email,email_verified) VALUES ($1,$2,false)", [userId, email]); - await client.query("INSERT INTO app.auth_accounts(id,user_id,provider,provider_account_id,password_hash) VALUES ($1,$2::uuid,'credential',$2::uuid::text,$3)", [crypto.randomUUID(), userId, await hashPassword(password)]); + await client.query("INSERT INTO app.auth_users(id,email,username,email_verified) VALUES ($1,$2,$3,false)", [userId, email, username]); + await client.query("INSERT INTO app.auth_accounts(id,user_id,provider,provider_account_id,password_hash) VALUES ($1,$2::uuid,'credential',$2::uuid::text,$3)", [crypto.randomUUID(), userId, passwordHash]); }); } catch (error: any) { - if (error?.code === "23505") return { status: 409, body: { error: "An account already exists for that email." } }; + if (error?.code === "23505") return { status: 409, body: { error: "That username or email is already registered. Sign in, resend verification, or reset your password." } }; throw error; } - return { status: 201, body: { userId, email, tenantId: userId, emailVerified: false } }; + await this.issueEmail(userId, email, "verify"); + return { status: 201, body: { ok: true } }; } async login(input: Record) { - const email = normalizeEmail(String(input.email ?? "")), password = String(input.password ?? ""); - const result = await this.database.pool.query<{ id: string; email: string; email_verified: boolean; password_hash: string | null }>(`SELECT u.id,u.email,u.email_verified,a.password_hash FROM app.auth_users u - LEFT JOIN app.auth_accounts a ON a.user_id=u.id AND a.provider='credential' WHERE u.email=$1`, [email]); - const row = result.rows[0]; - if (!row?.password_hash || !(await verifyPassword(password, row.password_hash))) return { status: 401, body: { error: "Invalid email or password." } }; - return { status: 200, body: { userId: row.id, email: row.email, tenantId: row.id, emailVerified: row.email_verified } }; + const identity = normalizeUsername(String(input.username ?? input.email ?? "")), password = String(input.password ?? ""); + return this.database.transaction(async client => { + const row = (await client.query<{ id: string; email: string; email_verified: boolean }>("SELECT id,email,email_verified FROM app.auth_users WHERE username=$1 OR lower(email)=$1 FOR UPDATE", [identity])).rows[0]; + const account = row ? (await client.query<{ password_hash: string }>("SELECT password_hash FROM app.auth_accounts WHERE user_id=$1 AND provider='credential'", [row.id])).rows[0] : null; + if (!validPassword(password) || !row || !account?.password_hash || !(await verifyPassword(password, account.password_hash))) return { status: 401, body: { error: "Invalid username or password." } }; + if (!row.email_verified) return { status: 403, body: { error: "Verify your email before signing in. You can resend the verification email below." } }; + // Existing accounts keep their existing workspace; never derive tenancy from OAuth. + const member = (await client.query<{ tenant_id: string; session_version: number }>("SELECT tenant_id,session_version FROM app.members WHERE user_id=$1 AND role='owner' ORDER BY created_at,tenant_id LIMIT 1", [row.id])).rows[0]; + if (!member) return { status: 403, body: { error: "This account needs a workspace owner invitation." } }; + return { status: 200, body: { userId: row.id, email: row.email, tenantId: member.tenant_id, sessionVersion: member.session_version } }; + }); } - async requestReset(input: Record) { - const email = normalizeEmail(String(input.email ?? "")); - const user = (await this.database.pool.query<{ id: string }>("SELECT id FROM app.auth_users WHERE email=$1", [email])).rows[0]; - if (!user) return { status: 200, body: { ok: true } }; + private async issueEmail(userId: string, email: string, purpose: "verify" | "reset") { const token = crypto.randomUUID() + crypto.randomUUID(), digest = await tokenDigest(token, this.env.SESSION_SIGNING_SECRET); - await this.database.pool.query("INSERT INTO app.auth_reset_tokens(digest,user_id,expires_at) VALUES ($1,$2,now()+interval '1 hour')", [digest, user.id]); - return { status: 200, body: { ok: true, ...(this.env.AUTH_RESET_RETURN_TOKEN === "true" ? { token } : {}) } }; + await this.database.pool.query("INSERT INTO app.auth_reset_tokens(digest,user_id,expires_at,purpose) VALUES ($1,$2,now()+interval '1 hour',$3)", [digest, userId, purpose]); + try { await sendAuthEmail(this.env, email, token, purpose); } + catch (error) { await this.database.pool.query("DELETE FROM app.auth_reset_tokens WHERE digest=$1", [digest]); throw error; } + } + + async requestEmail(input: Record, purpose: "verify" | "reset") { + validateAuthEmailConfig(this.env); + const email = normalizeEmail(String(input.email ?? "")); + const user = (await this.database.pool.query<{ id: string; email_verified: boolean }>("SELECT id,email_verified FROM app.auth_users WHERE lower(email)=$1", [email])).rows[0]; + if (user && (purpose === "reset" || !user.email_verified)) await this.issueEmail(user.id, email, purpose); + return ok(); + } + async requestReset(input: Record) { return this.requestEmail(input, "reset"); } + + private async ensureWorkspace(client: DatabaseClient, userId: string, email: string) { + // A legacy member must never become an owner simply by resetting a password. + const existing = await client.query("SELECT tenant_id FROM app.members WHERE user_id=$1", [userId]); + if (existing.rows.length) return; + await client.query("INSERT INTO app.tenants(id) VALUES ($1) ON CONFLICT DO NOTHING", [userId]); + await client.query("INSERT INTO app.members(tenant_id,user_id,email,role) VALUES ($1,$1,$2,'owner')", [userId, email]); } + async completeVerification(token: string) { return this.consumeToken(token, "verify"); } async completeReset(input: Record) { - const token = String(input.token ?? ""), password = String(input.password ?? ""); - if (!token || !validPassword(password)) return { status: 400, body: { error: "The reset link or password is invalid." } }; + const password = String(input.password ?? ""), username = normalizeUsername(String(input.username ?? "")); + if (!validPassword(password) || (username && !validUsername(username))) return { status: 400, body: { error: "Use a password of 12–200 characters and a valid username." } }; + return this.consumeToken(String(input.token ?? ""), "reset", await hashPassword(password), username); + } + private async consumeToken(token: string, purpose: "verify" | "reset", passwordHash?: string, username?: string) { + if (!token) return invalidLink(); const digest = await tokenDigest(token, this.env.SESSION_SIGNING_SECRET); - const completed = await this.database.transaction(async client => { - const row = (await client.query<{ user_id: string }>("SELECT user_id FROM app.auth_reset_tokens WHERE digest=$1 AND used_at IS NULL AND expires_at>now() FOR UPDATE", [digest])).rows[0]; - if (!row) return false; - await client.query("UPDATE app.auth_accounts SET password_hash=$1 WHERE user_id=$2 AND provider='credential'", [await hashPassword(password), row.user_id]); - await client.query("UPDATE app.auth_reset_tokens SET used_at=now() WHERE digest=$1", [digest]); - await client.query("UPDATE app.auth_sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL", [row.user_id]); + try { + const completed = await this.database.transaction(async client => { + // Always lock the user before tokens/accounts, including across different + // reset links. Recheck the token after the lock to prevent concurrent reuse. + const row = (await client.query<{ user_id: string; email: string; username: string | null }>(`SELECT u.id user_id,u.email,u.username FROM app.auth_users u WHERE u.id=( + SELECT user_id FROM app.auth_reset_tokens WHERE digest=$1 AND purpose=$2 AND used_at IS NULL AND expires_at>now()) FOR UPDATE`, [digest, purpose])).rows[0]; + if (!row) return false; + const active = await client.query("SELECT digest FROM app.auth_reset_tokens WHERE digest=$1 AND purpose=$2 AND used_at IS NULL AND expires_at>clock_timestamp() FOR UPDATE", [digest, purpose]); + if (!active.rows.length) return false; + if (purpose === "reset") { + if (!row.username && !username) return false; + if (!row.username) await client.query("UPDATE app.auth_users SET username=$1 WHERE id=$2", [username, row.user_id]); + // Email possession lets legacy Linear-only users establish native credentials. + await client.query(`INSERT INTO app.auth_accounts(id,user_id,provider,provider_account_id,password_hash) VALUES ($1,$2::uuid,'credential',$2::uuid::text,$3) + ON CONFLICT (provider,provider_account_id) DO UPDATE SET password_hash=excluded.password_hash`, [crypto.randomUUID(), row.user_id, passwordHash]); + await client.query("UPDATE app.members SET session_version=session_version+1 WHERE user_id=$1", [row.user_id]); + await client.query("UPDATE app.auth_sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL", [row.user_id]); + } + await client.query("UPDATE app.auth_users SET email_verified=true WHERE id=$1", [row.user_id]); + await this.ensureWorkspace(client, row.user_id, row.email); + await client.query("UPDATE app.auth_reset_tokens SET used_at=now() WHERE user_id=$1 AND purpose=$2 AND used_at IS NULL", [row.user_id, purpose]); + return true; + }); + return completed ? ok() : invalidLink(); + } catch (error: any) { + if (error?.code === "23505") return { status: 409, body: { error: "That username is unavailable." } }; + throw error; + } + } + + async changePassword(userId: string, input: Record) { + const password = String(input.password ?? ""), currentPassword = String(input.currentPassword ?? ""); + if (!validPassword(password) || !validPassword(currentPassword)) return { status: 400, body: { error: "Passwords must have 12–200 characters." } }; + const changed = await this.database.transaction(async client => { + // Same lock order as reset, preventing a stale current password from winning a race. + await client.query("SELECT id FROM app.auth_users WHERE id=$1 FOR UPDATE", [userId]); + const row = (await client.query<{ password_hash: string }>("SELECT password_hash FROM app.auth_accounts WHERE user_id=$1 AND provider='credential'", [userId])).rows[0]; + if (!row || !await verifyPassword(currentPassword, row.password_hash)) return false; + await client.query("UPDATE app.auth_accounts SET password_hash=$1 WHERE user_id=$2 AND provider='credential'", [await hashPassword(password), userId]); + await client.query("UPDATE app.members SET session_version=session_version+1 WHERE user_id=$1", [userId]); + await client.query("UPDATE app.auth_reset_tokens SET used_at=now() WHERE user_id=$1 AND used_at IS NULL", [userId]); return true; }); - return completed ? { status: 200, body: { ok: true } } : { status: 400, body: { error: "The reset link is invalid or expired." } }; + return changed ? ok() : { status: 401, body: { error: "Current password is incorrect." } }; } } diff --git a/packages/app/src/postgres/connection-repository.ts b/packages/app/src/postgres/connection-repository.ts index dfd2697..f6c63b4 100644 --- a/packages/app/src/postgres/connection-repository.ts +++ b/packages/app/src/postgres/connection-repository.ts @@ -16,6 +16,21 @@ export class ConnectionRepository { const encrypted = await encrypt(JSON.stringify(value), this.encryptionKey); await this.database.orm.insert(connections).values({ tenantId: this.tenantId, kind, encryptedValue: encrypted }).onConflictDoUpdate({ target: [connections.tenantId, connections.kind], set: { encryptedValue: encrypted, updatedAt: new Date() } }); } + async putLinear(organizationId: string, value: unknown): Promise { + const encrypted = await encrypt(JSON.stringify(value), this.encryptionKey); + try { + await this.database.transaction(async client => { + await client.query("DELETE FROM app.linear_workspaces WHERE tenant_id=$1 AND organization_id<>$2", [this.tenantId, organizationId]); + const result = await client.query("INSERT INTO app.linear_workspaces(organization_id,tenant_id) VALUES ($1,$2) ON CONFLICT (organization_id) DO UPDATE SET tenant_id=excluded.tenant_id WHERE app.linear_workspaces.tenant_id=excluded.tenant_id RETURNING tenant_id", [organizationId, this.tenantId]); + if (!result.rows.length) throw new Error("linear_workspace_claimed"); + await client.query("INSERT INTO app.connections(tenant_id,kind,encrypted_value) VALUES ($1,'linear',$2) ON CONFLICT (tenant_id,kind) DO UPDATE SET encrypted_value=excluded.encrypted_value,updated_at=now()", [this.tenantId, encrypted]); + }); + return true; + } catch (error) { + if (error instanceof Error && error.message === "linear_workspace_claimed") return false; + throw error; + } + } async delete(kind: string): Promise { return Boolean((await this.database.orm.delete(connections).where(and(eq(connections.tenantId, this.tenantId), eq(connections.kind, kind))).returning({ kind: connections.kind })).length); } async kinds(prefix?: string): Promise { const rows = await this.database.orm.select({ kind: connections.kind }).from(connections).where(and(eq(connections.tenantId, this.tenantId), ...(prefix ? [like(connections.kind, `${prefix}%`)] : []))).orderBy(connections.kind); diff --git a/packages/app/src/postgres/identity-repository.ts b/packages/app/src/postgres/identity-repository.ts index 384c029..a280879 100644 --- a/packages/app/src/postgres/identity-repository.ts +++ b/packages/app/src/postgres/identity-repository.ts @@ -9,7 +9,7 @@ export class IdentityRepository { async member(userId: string): Promise { const result = await this.database.pool.query<{ user_id: string; email: string; role: Member["role"]; session_version: number }>( - "SELECT user_id,email,role,session_version FROM app.members WHERE tenant_id=$1 AND user_id=$2", [this.tenantId, userId]); + "SELECT m.user_id,m.email,m.role,m.session_version FROM app.members m JOIN app.auth_users u ON u.id=m.user_id WHERE m.tenant_id=$1 AND m.user_id=$2 AND u.email_verified", [this.tenantId, userId]); const row = result.rows[0]; return row ? { userId: row.user_id, email: row.email, role: row.role, sessionVersion: row.session_version } : null; } diff --git a/packages/app/src/postgres/schema.ts b/packages/app/src/postgres/schema.ts index 4c961d8..b212ab7 100644 --- a/packages/app/src/postgres/schema.ts +++ b/packages/app/src/postgres/schema.ts @@ -17,11 +17,11 @@ export const schemaMigrations = app.table("schema_migrations", { }); export const tenants = app.table("tenants", { id: uuid("id").primaryKey(), name: text("name"), createdAt: createdAt(), updatedAt: updatedAt() }); -export const authUsers = app.table("auth_users", { id: uuid("id").primaryKey(), email: text("email").notNull().unique(), emailVerified: boolean("email_verified").notNull().default(false), createdAt: createdAt() }); +export const authUsers = app.table("auth_users", { id: uuid("id").primaryKey(), email: text("email").notNull().unique(), username: text("username").unique(), emailVerified: boolean("email_verified").notNull().default(false), createdAt: createdAt() }, t => [uniqueIndex("auth_users_email_normalized").on(sql`lower(${t.email})`), check("auth_users_username_check", sql`${t.username} IS NULL OR ${t.username} ~ '^[a-z0-9_][a-z0-9_-]{2,31}$'`)]); export const authAccounts = app.table("auth_accounts", { id: uuid("id").primaryKey(), userId: uuid("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }), provider: text("provider").notNull(), providerAccountId: text("provider_account_id").notNull(), passwordHash: text("password_hash"), }, t => [unique().on(t.provider, t.providerAccountId)]); -export const authResetTokens = app.table("auth_reset_tokens", { digest: text("digest").primaryKey(), userId: uuid("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), usedAt: timestamp("used_at", { withTimezone: true }) }); +export const authResetTokens = app.table("auth_reset_tokens", { purpose: text("purpose").$type<"verify" | "reset">().notNull().default("reset"), digest: text("digest").primaryKey(), userId: uuid("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), usedAt: timestamp("used_at", { withTimezone: true }) }, t => [check("auth_reset_tokens_purpose_check", sql`${t.purpose} IN ('verify','reset')`)]); export const authSessions = app.table("auth_sessions", { id: uuid("id").primaryKey(), userId: uuid("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), revokedAt: timestamp("revoked_at", { withTimezone: true }) }); export const oauthDeviceAuthorizations = app.table("oauth_device_authorizations", { deviceCode: text("device_code").primaryKey(), userCode: text("user_code").notNull().unique(), record: jsonb("record").$type>().notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), createdAt: createdAt(), updatedAt: updatedAt(), @@ -34,6 +34,7 @@ export const accessTokens = app.table("access_tokens", { }, t => [primaryKey({ columns: [t.tenantId, t.id] }), foreignKey({ columns: [t.tenantId, t.userId], foreignColumns: [members.tenantId, members.userId] }).onDelete("cascade")]); export const connections = app.table("connections", { tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }), kind: text("kind").notNull(), encryptedValue: text("encrypted_value").notNull(), updatedAt: updatedAt() }, t => [primaryKey({ columns: [t.tenantId, t.kind] })]); +export const linearWorkspaces = app.table("linear_workspaces", { organizationId: uuid("organization_id").primaryKey(), tenantId: uuid("tenant_id").notNull().unique().references(() => tenants.id, { onDelete: "cascade" }) }); export const githubInstallations = app.table("github_installations", { installationId: bigint("installation_id", { mode: "number" }).primaryKey(), tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }), accountLogin: text("account_login").notNull(), accountType: text("account_type").notNull(), state: text("state").$type<"active" | "suspended">().notNull(), updatedAt: updatedAt() }, t => [unique().on(t.tenantId, t.installationId), check("github_installations_state_check", sql`${t.state} in ('active','suspended')`)]); export const githubSetupStates = app.table("github_setup_states", { tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }), nonce: text("nonce").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), usedAt: timestamp("used_at", { withTimezone: true }) }, t => [primaryKey({ columns: [t.tenantId, t.nonce] })]); @@ -99,7 +100,7 @@ export const runRelations = relations(runs, ({ one, many }) => ({ export const databaseSchema = { schemaMigrations, tenants, authUsers, authAccounts, authResetTokens, authSessions, oauthDeviceAuthorizations, members, accessTokens, - connections, githubInstallations, githubSetupStates, jobs, triggers, invocations, jobRuns, runs, runArtifacts, + connections, linearWorkspaces, githubInstallations, githubSetupStates, jobs, triggers, invocations, jobRuns, runs, runArtifacts, runTraceEvents, runActivity, activeClaims, scheduleState, automaticWakes, lifecycleDeliveries, jobEditDeliveries, jobEvents, webhookDeliveries, webhookDeliveryEvents, pendingVerifications, tailFingerprints, wakeHints, runTraceCursors, runTraceChunks, tenantRelations, jobRelations, triggerRelations, invocationRelations, runRelations, diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index d0e4d7e..cc8e9d3 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -24,7 +24,9 @@ export interface Env { RECOVERY_MAX_ATTEMPTS?: string; OAUTH_KV?: KVNamespace; OAUTH_PROVIDER?: import("@cloudflare/workers-oauth-provider").OAuthHelpers; - AUTH_RESET_RETURN_TOKEN?: string; + POSTMARK_SERVER_TOKEN?: string; + POSTMARK_FROM_EMAIL?: string; + POSTMARK_MESSAGE_STREAM?: string; RUN_ARTIFACTS?: R2Bucket; HYPERDRIVE?: Hyperdrive; /** Test-only injection point; production uses HYPERDRIVE. */ diff --git a/packages/app/src/ui.ts b/packages/app/src/ui.ts index 938db28..7e836c7 100644 --- a/packages/app/src/ui.ts +++ b/packages/app/src/ui.ts @@ -6,7 +6,27 @@ import { tracePresentationBrowserSource } from "./trace-presentation"; type Viewer = { email: string } | null; const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!); -export const authPage = (title: string, mode: "signup" | "login" | "reset" | "complete", value = "", error = "") => `${escapeHtml(title)} — Factorize
Factorize

${escapeHtml(title)}

${error ? `` : ""}${mode === "reset" ? `

If an account exists, we’ll send a password reset link.

` : ""}
${mode === "complete" ? `` : ``}${mode !== "reset" && mode !== "complete" ? `` : ""}${mode === "complete" ? `` : ""}
${mode === "login" ? `Forgot password?

New here? Create an account

` : mode === "signup" ? `

Already have an account? Sign in

` : ""}
`; +export const authPage = (title: string, mode: "signup" | "login" | "reset" | "complete" | "verify" | "verify-request" | "change", value = "", error = "") => { + const input = (label: string, name: string, type: string, autocomplete: string, extra = "") => ``; + const actions = { signup: "/auth/signup", login: "/auth/login", reset: "/auth/password-reset", complete: "/auth/password-reset/complete", verify: "/auth/verify", "verify-request": "/auth/verify/request", change: "/auth/password-change" }; + const labels = { signup: "Create account", login: "Sign in", reset: "Send reset link", complete: "Set new password", verify: "Verify email", "verify-request": "Resend verification email", change: "Change password" }; + return `${escapeHtml(title)} — Factorize +
+Factorize

${escapeHtml(title)}

+${error ? `` : ""} +${mode === "reset" || mode === "verify-request" ? `

If an eligible account exists, we’ll send a link valid for one hour.

` : ""} +
+${mode === "complete" || mode === "verify" ? `` : ""} +${mode === "signup" || mode === "login" ? input(mode === "login" ? "Username or email" : "Username", "username", "text", "username", mode === "signup" ? 'minlength="3" maxlength="32" pattern="[A-Za-z0-9_][A-Za-z0-9_-]{2,31}"' : `value="${escapeHtml(value)}"`) : ""} +${mode === "signup" || mode === "reset" || mode === "verify-request" ? input("Email", "email", "email", "email") : ""} +${mode === "complete" ? `

Migrating from Linear sign-in? Choose a username to keep using your existing workspace.

` : ""} +${mode === "change" ? input("Current password", "currentPassword", "password", "current-password", 'maxlength="200"') : ""} +${["signup", "login", "complete", "change"].includes(mode) ? input(mode === "change" || mode === "complete" ? "New password" : "Password", "password", "password", mode === "login" ? "current-password" : "new-password", 'minlength="12" maxlength="200"') : ""} +
+ +${mode === "change" ? `

Changing your password signs out existing sessions. Sign in again with your new password.

Back to settings` : ""} +
`; +}; /** The app entry point is intentionally separate from the public marketing site. */ export const loginPage = (marketingOrigin = "https://factorize.sh") => ` @@ -15,8 +35,8 @@ export const loginPage = (marketingOrigin = "https://factorize.sh") => `
Factorize

Sign in to Factorize

Manage your software factory and its Jobs.

-Continue with Linear -

New here? Sign in to create your workspace. Email, Google, and Linear sign-in can be added as authentication providers without changing your Jobs.

+Sign in with your Factorize account +

New here? Create an account, verify your email, then connect your integrations.

`; const themeScript = ` @@ -45,7 +65,7 @@ const shell = (title: string, viewer: Viewer, body: string, script = "") => `${body}`; @@ -76,7 +96,7 @@ const uiHelpers = ` const $=s=>document.querySelector(s),esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); ${ansiToHtmlBrowserSource} ${pageRefreshBrowserSource} -const request=async(url,options)=>{const requestUrl=String(url).startsWith('/api/v1/runs?jobId=')&&!String(url).includes('limit=')?String(url)+'&limit=20':url,r=await fetch(requestUrl,options),body=await r.json().catch(()=>({}));if(r.status===401){location.href='/auth/linear';throw new Error('Unauthorized')}if(!r.ok)throw new Error(body.error?.message||body.error||'Request failed');return body}; +const request=async(url,options)=>{const requestUrl=String(url).startsWith('/api/v1/runs?jobId=')&&!String(url).includes('limit=')?String(url)+'&limit=20':url,r=await fetch(requestUrl,options),body=await r.json().catch(()=>({}));if(r.status===401){location.href='/auth/login';throw new Error('Unauthorized')}if(!r.ok)throw new Error(body.error?.message||body.error||'Request failed');return body}; const jobRunsPageSize=20,jobRunsState={page:1,cursors:[null],runs:null,loading:false}; const jobRunsMarkup=r=>r.items?.length?'':'

No runs yet.

'; const jobRunsControls=(r,page)=>{if(page===1&&!r.nextCursor)return'';const previous=page>1?'':' disabled aria-disabled="true"',next=r.nextCursor?'':' disabled aria-disabled="true"';return''}; diff --git a/packages/app/test/device-oauth.test.ts b/packages/app/test/device-oauth.test.ts index 5533138..f71ba3a 100644 --- a/packages/app/test/device-oauth.test.ts +++ b/packages/app/test/device-oauth.test.ts @@ -28,7 +28,7 @@ describe("OAuth device authorization", () => { const response = deviceLoginRedirect("https://factorize.test", "/device?user_code=ABCD-2345"); expect(response.status).toBe(302); expect(response.headers.get("cache-control")).toBe("no-store"); - expect(response.headers.get("location")).toBe("https://factorize.test/auth/linear"); + expect(response.headers.get("location")).toBe("https://factorize.test/auth/login"); expect(response.headers.get("set-cookie")).toContain("factorize_oauth_return=%2Fdevice%3Fuser_code%3DABCD-2345"); }); diff --git a/packages/app/test/native-auth.postgres.test.ts b/packages/app/test/native-auth.postgres.test.ts new file mode 100644 index 0000000..9880e07 --- /dev/null +++ b/packages/app/test/native-auth.postgres.test.ts @@ -0,0 +1,197 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { readFile, readdir } from "node:fs/promises"; +import { Database } from "../src/postgres/database"; +import { AuthRepository } from "../src/postgres/auth-repository"; +import { ConnectionRepository } from "../src/postgres/connection-repository"; +import { IdentityRepository } from "../src/postgres/identity-repository"; +import { ApiService } from "../src/flow-service"; +import { tokenDigest } from "../src/auth"; +import { hmac } from "../src/crypto"; +import { signSetupState } from "../src/github"; +import app, { readSession, signSession } from "../src/index"; +import type { Env } from "../src/types"; + +// CI provides a disposable PostgreSQL service. Each suite owns a fresh database. +describe.skipIf(!process.env.AUTH_TEST_DATABASE_URL)("native authentication with PostgreSQL", () => { + let db: Database, admin: Database, env: Env, auth: AuthRepository; + const databaseName = `auth_test_${crypto.randomUUID().replaceAll("-", "")}`; + const legacyUser = crypto.randomUUID(), legacyTenant = crypto.randomUUID(); + const password = "correct horse battery staple"; + let sent: Array<{ To: string; TextBody: string; Subject: string }> = []; + beforeAll(async () => { + admin = new Database({ connectionString: process.env.AUTH_TEST_DATABASE_URL }); + await admin.pool.query(`CREATE DATABASE ${databaseName}`); + const url = new URL(process.env.AUTH_TEST_DATABASE_URL!); url.pathname = `/${databaseName}`; + db = new Database({ connectionString: url.toString() }); + const dir = new URL("../migrations/", import.meta.url); + for (const file of (await readdir(dir)).filter(f => f.endsWith(".sql")).sort()) { + if (file === "0008_native_accounts.sql") { + await new IdentityRepository(db, legacyTenant).upsertOwner(legacyUser, "Legacy@Example.test"); + await new ConnectionRepository(db, legacyTenant, btoa("a".repeat(32))).put("linear", { organizationId: legacyTenant, accessToken: "existing-token" }); + } + await db.pool.query(await readFile(new URL(file, dir), "utf8")); + } + env = { DATABASE: db, APP_ORIGIN: "https://factorize.test", SESSION_SIGNING_SECRET: "test-signing-secret", CREDENTIAL_ENCRYPTION_KEY: btoa("a".repeat(32)), POSTMARK_SERVER_TOKEN: "test-postmark", POSTMARK_FROM_EMAIL: "accounts@example.test", POSTMARK_MESSAGE_STREAM: "outbound", LINEAR_CLIENT_ID: "client", LINEAR_CLIENT_SECRET: "test-client-secret", LINEAR_WEBHOOK_SIGNING_SECRET: "test-webhook" } as Env; + auth = new AuthRepository(db, env); + }); + afterEach(() => { vi.unstubAllGlobals(); sent = []; }); + afterAll(async () => { await db?.close(); if (admin) { await admin.pool.query(`DROP DATABASE IF EXISTS ${databaseName} WITH (FORCE)`); await admin.close(); } }); + function mail() { + vi.stubGlobal("fetch", vi.fn(async (url: string, init: RequestInit) => { + expect(url).toBe("https://api.postmarkapp.com/email"); + expect(init.headers).toMatchObject({ "X-Postmark-Server-Token": "test-postmark" }); + const body = JSON.parse(String(init.body)); expect(body.MessageStream).toBe("outbound"); sent.push(body); + return Response.json({ ErrorCode: 0 }); + })); + } + function token(index = sent.length - 1) { return new URL(sent[index]!.TextBody.match(/https:\/\/\S+/)![0]).searchParams.get("token")!; } + async function registered() { + mail(); const username = `user_${crypto.randomUUID().slice(0, 8)}`, email = `${username}@example.test`; + expect((await auth.signup({ username, email, password })).status).toBe(201); + return { username, email, verification: token() }; + } + async function verified() { const user = await registered(); expect((await auth.completeVerification(user.verification)).status).toBe(200); return user; } + async function sessionFor(username: string) { + const login = await auth.login({ username, password }); expect(login.status).toBe(200); + const identity = login.body as { tenantId: string; userId: string; email: string }; + const member = await new IdentityRepository(db, identity.tenantId).member(identity.userId); + const session = { ...identity, sessionVersion: member!.sessionVersion, exp: Math.floor(Date.now() / 1000) + 3600 }; + return { ...session, cookie: `factorize_session=${await signSession(session, env.SESSION_SIGNING_SECRET)}` }; + } + function post(path: string, body: Record, cookie = "", origin = env.APP_ORIGIN) { + return app.request(path, { method: "POST", headers: { Origin: origin, Cookie: cookie, "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(body) }, env); + } + + it("requires username, delivers verification, and denies access before verification", async () => { + mail(); expect((await auth.signup({ email: "bad@example.test", password })).status).toBe(400); + const user = await registered(); + expect(sent[0]!.Subject).toBe("Verify your Factorize email"); + expect((await auth.login({ username: user.username, password })).status).toBe(403); + expect((await auth.completeReset({ token: user.verification, password })).status).toBe(400); + expect((await auth.completeVerification(user.verification)).status).toBe(200); + expect((await auth.completeVerification(user.verification)).status).toBe(400); + expect((await auth.login({ username: user.username.toUpperCase(), password })).status).toBe(200); + expect((await auth.login({ username: user.username, password: "incorrect password" })).status).toBe(401); + expect((await auth.signup({ username: user.username.toUpperCase(), email: "other@example.test", password })).status).toBe(409); + }); + it("signup never sets a session; email link GET does not consume verification", async () => { + mail(); const username = `route_${crypto.randomUUID().slice(0, 8)}`; + const response = await post("/auth/signup", { username, email: `${username}@example.test`, password }); + expect(response.status).toBe(201); expect(response.headers.get("set-cookie")).toBeNull(); + const link = token(); expect((await app.request(`/auth/verify?token=${link}`, {}, env)).status).toBe(200); + expect((await auth.login({ username, password })).status).toBe(403); + expect((await post("/auth/verify", { token: link })).status).toBe(303); + const login = await post("/auth/login", { username, password }); expect(login.status).toBe(303); + expect(login.headers.get("location")).toBe("/settings/integrations"); expect(login.headers.get("set-cookie")).toContain("HttpOnly"); + const loggedIn = await sessionFor(username); + expect(await (await app.request("/jobs", { headers: { Cookie: loggedIn.cookie } }, env)).text()).toContain('href="/settings/password"'); + }); + it("expires verification and supports resending without changing credentials", async () => { + const user = await registered(); + await db.pool.query("UPDATE app.auth_reset_tokens SET expires_at=now()-interval '1 second' WHERE digest=$1", [await tokenDigest(user.verification, env.SESSION_SIGNING_SECRET)]); + expect((await auth.completeVerification(user.verification)).status).toBe(400); + await auth.requestEmail({ email: user.email }, "verify"); + expect((await auth.completeVerification(token())).status).toBe(200); + }); + it("sends reset email, enforces purpose and expiry, consumes once, and revokes browser sessions", async () => { + const user = await verified(), session = await sessionFor(user.username); + expect((await app.request("/jobs", { headers: { Cookie: session.cookie } }, env)).status).toBe(200); + await auth.requestReset({ email: user.email }); const reset = token(); + expect((await auth.completeVerification(reset)).status).toBe(400); + const newPassword = "replacement long password"; + expect((await auth.completeReset({ token: reset, password: newPassword })).status).toBe(200); + expect((await auth.completeReset({ token: reset, password })).status).toBe(400); + expect((await auth.login({ username: user.username, password })).status).toBe(401); + expect((await auth.login({ username: user.username, password: newPassword })).status).toBe(200); + expect((await app.request("/jobs", { headers: { Cookie: session.cookie } }, env)).headers.get("location")).toBe("/auth/login"); + await auth.requestReset({ email: user.email }); const expired = token(); + await db.pool.query("UPDATE app.auth_reset_tokens SET expires_at=now()-interval '1 second' WHERE digest=$1", [await tokenDigest(expired, env.SESSION_SIGNING_SECRET)]); + expect((await auth.completeReset({ token: expired, password })).status).toBe(400); + const count = sent.length; expect((await auth.requestReset({ email: "unknown@example.test" })).body).toEqual({ ok: true }); expect(sent).toHaveLength(count); + }); + it("serializes concurrent attempts to redeem a reset link", async () => { + const user = await verified(); await auth.requestReset({ email: user.email }); const reset = token(); + const outcomes = await Promise.all([auth.completeReset({ token: reset, password }), auth.completeReset({ token: reset, password })]); + expect(outcomes.map(r => r.status).sort()).toEqual([200, 400]); + }); + it("serializes different reset links and invalidates delegated API access", async () => { + const user = await verified(), session = await sessionFor(user.username); + const service = new ApiService(env, { ...session, scopes: ["flows:read"], authMethod: "oauth" }); + expect(await service.listJobs()).toEqual([]); + await expect(new ApiService(env, { ...session, tenantId: legacyTenant, scopes: ["flows:read"], authMethod: "oauth" }).listJobs()).rejects.toMatchObject({ status: 401 }); + await auth.requestReset({ email: user.email }); const first = token(); + await auth.requestReset({ email: user.email }); const second = token(); + const outcomes = await Promise.all([auth.completeReset({ token: first, password }), auth.completeReset({ token: second, password })]); + expect(outcomes.map(r => r.status).sort()).toEqual([200, 400]); + await expect(service.listJobs()).rejects.toMatchObject({ status: 401 }); + }); + it("requires current password for change, rejects CSRF, and invalidates sessions on logout", async () => { + const user = await verified(), session = await sessionFor(user.username); + expect((await post("/auth/password-change", { currentPassword: password, password }, session.cookie, "https://evil.test")).status).toBe(403); + expect((await post("/auth/password-change", { currentPassword: "incorrect password", password }, session.cookie)).status).toBe(401); + const changed = await post("/auth/password-change", { currentPassword: password, password: "new changed password" }, session.cookie); + expect(changed.status).toBe(303); expect((await auth.login({ username: user.username, password: "new changed password" })).status).toBe(200); + expect((await app.request("/jobs", { headers: { Cookie: session.cookie } }, env)).headers.get("location")).toBe("/auth/login"); + const other = await verified(), active = await sessionFor(other.username); + expect((await post("/auth/logout", {}, active.cookie)).status).toBe(302); + expect((await app.request("/jobs", { headers: { Cookie: active.cookie } }, env)).headers.get("location")).toBe("/auth/login"); + }); + it("migrates legacy Linear users through email proof while preserving workspace and connection", async () => { + mail(); + expect((await auth.signup({ username: "legacy_imposter", email: "legacy@example.test", password })).status).toBe(409); + await auth.requestReset({ email: "legacy@example.test" }); + expect((await auth.completeReset({ token: token(), password })).status).toBe(400); + expect((await auth.completeReset({ token: token(), password, username: "legacy_owner" })).status).toBe(200); + const login = await auth.login({ username: "legacy_owner", password }); + expect(login.body).toMatchObject({ userId: legacyUser, tenantId: legacyTenant }); + expect(await new ConnectionRepository(db, legacyTenant, env.CREDENTIAL_ENCRYPTION_KEY).get("linear")).toMatchObject({ accessToken: "existing-token" }); + expect((await db.pool.query("SELECT tenant_id FROM app.linear_workspaces WHERE organization_id=$1", [legacyTenant])).rows[0].tenant_id).toBe(legacyTenant); + }); + it("does not promote legacy members on reset or accept a cookie for another tenant", async () => { + const userId = crypto.randomUUID(); await new IdentityRepository(db, legacyTenant).upsertOwner(userId, "member@example.test"); + mail(); await auth.requestReset({ email: "member@example.test" }); + expect((await auth.completeReset({ token: token(), password, username: "legacy_member" })).status).toBe(200); + expect((await auth.login({ username: "legacy_member", password })).status).toBe(403); + const user = await verified(), session = await sessionFor(user.username); + const forgedTenantCookie = await signSession({ ...session, tenantId: legacyTenant }, env.SESSION_SIGNING_SECRET); + expect((await app.request("/jobs", { headers: { Cookie: `factorize_session=${forgedTenantCookie}` } }, env)).headers.get("location")).toBe("/auth/login"); + }); + it("uses native login as the primary entry and requires authentication to connect Linear", async () => { + const home = await app.request("/", {}, env), html = await home.text(); + expect(html).toContain('href="/auth/login"'); expect(html).not.toContain('href="/auth/linear"'); + expect((await app.request("/auth/linear", {}, env)).headers.get("location")).toBe("/auth/login"); + expect((await app.request("/auth/linear/callback?code=fake&state=fake", {}, env)).status).toBe(400); + }); + it("binds Linear OAuth to the authenticated account and routes webhooks through the explicit mapping", async () => { + const user = await verified(), session = await sessionFor(user.username), organizationId = crypto.randomUUID(); + const start = await app.request("/auth/linear", { headers: { Cookie: session.cookie } }, env); + const state = new URL(start.headers.get("location")!).searchParams.get("state")!; + const cookie = `${session.cookie}; linear_oauth_state=${encodeURIComponent(state)}`; + vi.stubGlobal("fetch", vi.fn(async (url: string) => url.endsWith("/oauth/token") ? Response.json({ access_token: "new-token", refresh_token: "refresh" }) : Response.json({ data: { viewer: { id: crypto.randomUUID(), email: "linear@example.test" }, organization: { id: organizationId, name: "Workspace" } } }))); + const callback = await app.request(`/auth/linear/callback?code=code&state=${encodeURIComponent(state)}`, { headers: { Cookie: cookie } }, env); + expect(callback.status).toBe(302); expect(callback.headers.get("location")).toBe("/settings/integrations"); + expect(callback.headers.get("set-cookie")).not.toContain("factorize_session"); + expect((await db.pool.query("SELECT tenant_id FROM app.linear_workspaces WHERE organization_id=$1", [organizationId])).rows[0].tenant_id).toBe(session.tenantId); + expect(await new ConnectionRepository(db, session.tenantId, env.CREDENTIAL_ENCRYPTION_KEY).get("linear")).toMatchObject({ organizationId }); + const raw = JSON.stringify({ organizationId, webhookTimestamp: Date.now(), type: "Issue", action: "create" }); + const webhook = await app.request("/webhooks/linear", { method: "POST", body: raw, headers: { "linear-delivery": "delivery", "linear-signature": Buffer.from(await hmac(raw, env.LINEAR_WEBHOOK_SIGNING_SECRET), "base64").toString("hex") } }, env); + expect(webhook.status).toBe(200); + expect((await db.pool.query("SELECT tenant_id FROM app.webhook_deliveries WHERE delivery_id='linear:delivery'")).rows[0].tenant_id).toBe(session.tenantId); + const other = await verified(), otherSession = await sessionFor(other.username); + expect((await app.request(`/auth/linear/callback?code=code&state=${encodeURIComponent(state)}`, { headers: { Cookie: `${otherSession.cookie}; linear_oauth_state=${encodeURIComponent(state)}` } }, env)).status).toBe(400); + expect(await new ConnectionRepository(db, otherSession.tenantId, env.CREDENTIAL_ENCRYPTION_KEY).putLinear(organizationId, {})).toBe(false); + expect(await new ConnectionRepository(db, otherSession.tenantId, env.CREDENTIAL_ENCRYPTION_KEY).get("linear")).toBeNull(); + const expired = await signSetupState({ tenantId: session.tenantId, userId: session.userId, nonce: "expired", exp: 1 }, env.SESSION_SIGNING_SECRET); + expect((await app.request(`/auth/linear/callback?code=code&state=${expired}`, { headers: { Cookie: `${session.cookie}; linear_oauth_state=${expired}` } }, env)).status).toBe(400); + expect(await readSession(session.cookie.slice("factorize_session=".length), env.SESSION_SIGNING_SECRET)).toMatchObject({ tenantId: session.tenantId }); + }); + it("fails closed on missing Postmark configuration and allows retry after delivery failure", async () => { + const broken = new AuthRepository(db, { ...env, POSTMARK_SERVER_TOKEN: undefined }); + await expect(broken.signup({ username: "missing_config", email: "config@example.test", password })).rejects.toThrow("Authentication email requires"); + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ErrorCode: 400 }, { status: 422 }))); + await expect(auth.signup({ username: "delivery_retry", email: "retry@example.test", password })).rejects.toThrow("Postmark"); + expect((await auth.login({ username: "delivery_retry", password })).status).toBe(403); + mail(); await auth.requestEmail({ email: "retry@example.test" }, "verify"); + expect((await auth.completeVerification(token())).status).toBe(200); + }); +}); diff --git a/packages/docs/api/authentication.mdx b/packages/docs/api/authentication.mdx index 6073109..3612b77 100644 --- a/packages/docs/api/authentication.mdx +++ b/packages/docs/api/authentication.mdx @@ -5,6 +5,12 @@ description: OAuth 2.1, device authorization, and access-token rules. Protected API and MCP requests use `Authorization: Bearer `. Browser users may also use their signed Factorize session cookie. Access is tenant-bound; owner membership and session version are rechecked on every service call. +## Browser accounts + +Create a username/password account at `/auth/signup` and verify the email delivered by Postmark before signing in at `/auth/login`. Recovery is at `/auth/password-reset`; legacy Linear-only users use that flow to choose a username/password while retaining their workspace. Change your password from the account menu. Resetting/changing passwords or signing out invalidates existing sessions and delegated credentials. + +Linear OAuth is an integration connection available only after native sign-in. Workspace membership, not the connected Linear identity, controls authorization. + ## Scopes | Scope | Allows | diff --git a/packages/docs/integrations/oauth.mdx b/packages/docs/integrations/oauth.mdx index d40eeeb..697e5e4 100644 --- a/packages/docs/integrations/oauth.mdx +++ b/packages/docs/integrations/oauth.mdx @@ -3,6 +3,6 @@ title: Provider OAuth and boundaries description: Configure Linear and optional ClickUp connections. --- -Linear is the required dashboard identity and webhook provider. Register `/auth/linear/callback`, configure issue and issue-label webhooks at `/webhooks/linear`, and store the client credentials and signing secret as Worker secrets. ClickUp is optional: register `/auth/clickup/callback`; connecting it automatically registers its task webhook. GitHub uses an installed GitHub App, while exe.dev and Cloudflare Tail connections are configured in Settings. +Factorize accounts own dashboard identity. Sign up with a username, email and password, verify your email, then connect Linear from Settings → Integrations when using Linear-backed jobs. Linear OAuth only connects the workspace; it never signs you in. Register `/auth/linear/callback`, configure issue and issue-label webhooks at `/webhooks/linear`, and store the client credentials and signing secret as Worker secrets. ClickUp is optional: register `/auth/clickup/callback`; connecting it automatically registers its task webhook. GitHub uses an installed GitHub App, while exe.dev and Cloudflare Tail connections are configured in Settings. Provider credentials are encrypted at rest in the tenant Durable Object. Public REST/MCP APIs expose only safe metadata. Owner membership and session version are enforced at the shared service boundary. diff --git a/packages/docs/quickstart.mdx b/packages/docs/quickstart.mdx index 37b2335..872d9b1 100644 --- a/packages/docs/quickstart.mdx +++ b/packages/docs/quickstart.mdx @@ -5,9 +5,9 @@ description: Configure authentication and create a job end to end. ## Prerequisites -You need a Cloudflare Worker deployment, a Linear OAuth application, and an exe.dev account-level HTTPS API token allowed to run `ls`, `new`, `ssh`, and `rm`. Agents are installed on each newly-created VM. Set `APP_ORIGIN`, OAuth credentials, `LINEAR_WEBHOOK_SIGNING_SECRET`, `CREDENTIAL_ENCRYPTION_KEY`, and `SESSION_SIGNING_SECRET`. +You need a Cloudflare Worker deployment, a Postmark server with a verified sender and transactional message stream, and an exe.dev account-level HTTPS API token allowed to run `ls`, `new`, `ssh`, and `rm`. Agents are installed on each newly-created VM. For Linear-driven jobs, also configure a Linear OAuth application. Set `POSTMARK_SERVER_TOKEN`, `POSTMARK_FROM_EMAIL`, `POSTMARK_MESSAGE_STREAM`, `APP_ORIGIN`, OAuth credentials, `LINEAR_WEBHOOK_SIGNING_SECRET`, `CREDENTIAL_ENCRYPTION_KEY`, and `SESSION_SIGNING_SECRET`. -Sign in to the dashboard and configure an exe.dev execution target with its token, repository URL, optional checkout ref, agent, and VM tags. Tags attach the integrations required by the repository and agent. Use `list_execution_targets` or `GET /api/v1/execution-targets` before submitting `executionTargetId`. +Create a native Factorize account with a username, email and password. Verify your email, sign in, connect Linear if needed in Settings → Integrations, and configure an exe.dev execution target with its token, repository URL, optional checkout ref, agent, and VM tags. Tags attach the integrations required by the repository and agent. Use `list_execution_targets` or `GET /api/v1/execution-targets` before submitting `executionTargetId`. ## First API call