diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca533906a..4e49d6b50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,6 +99,11 @@ bun run db:migrate bun dev ``` +On first run, approve Portless's local certificate prompt. The apps are then +available at `https://app.notra.localhost`, `https://notra.localhost`, +`https://api.notra.localhost`, and `https://docs.notra.localhost`. Portless +prefixes these hostnames automatically in linked Git worktrees. + Run a single app when needed: ```bash @@ -108,6 +113,9 @@ bun dev --filter=web bun dev --filter=docs ``` +The React Email preview is available at `https://email.notra.localhost` when +running all development tasks, or on its own with `bun run email:dev`. + ## QStash Local Workflows If you're testing webhooks or workflows with QStash, set `NEXT_PUBLIC_APP_URL` to a public URL. `localhost` will not work for external callbacks. diff --git a/README.md b/README.md index 3a1137c0d..1760b646f 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,29 @@ bun install bun run dev ``` +Development servers run through [Portless](https://portless.sh/), which assigns +free internal ports and exposes stable HTTPS URLs: + +| Service | Local URL | +| --- | --- | +| Dashboard | `https://app.notra.localhost` | +| Website | `https://notra.localhost` | +| API | `https://api.notra.localhost` | +| Docs | `https://docs.notra.localhost` | +| Email preview | `https://email.notra.localhost` | + +The first run asks to trust Portless's local certificate authority. Linked Git +worktrees automatically receive a hostname prefix, so their servers do not +collide with the main checkout. + To run only the dashboard app: ```bash bun run dev --filter=dashboard ``` +Set `PORTLESS=0` to bypass the proxy and use the apps' fallback ports. + To build the dashboard app: ```bash diff --git a/apps/api/package.json b/apps/api/package.json index 59dbbb6d8..8903e161b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -2,12 +2,17 @@ "name": "api", "type": "module", "scripts": { - "dev": "NODE_ENV=development bun --watch src/index.ts", + "dev": "portless", + "dev:app": "NODE_ENV=development bun --watch src/index.ts", "build": "tsup", "start": "node dist/index.mjs", "check-types": "bunx tsc --noEmit", "knip": "knip" }, + "portless": { + "name": "api.notra", + "script": "dev:app" + }, "dependencies": { "@contextcompany/otel": "^1.0.16", "@hono/zod-openapi": "^1.2.2", @@ -36,6 +41,7 @@ "@types/sanitize-html": "^2.16.0", "@notra/typescript-config": "workspace:*", "knip": "^6.12.1", + "portless": "^0.15.1", "typescript": "^5" } } diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 4da492fd1..4c29188c6 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -3,7 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "portless", + "dev:app": "next dev --port ${PORT:-3000}", "build": "next build", "start": "next start", "check-types": "tsc --noEmit", @@ -15,6 +16,10 @@ "backfill:system-skills": "bun run scripts/backfill-system-skills.ts", "backfill:collection-titles": "bun run scripts/backfill-collection-titles.ts" }, + "portless": { + "name": "app.notra", + "script": "dev:app" + }, "dependencies": { "@ai-sdk/react": "3.0.208", "@aws-sdk/client-s3": "^3.990.0", @@ -110,6 +115,7 @@ "drizzle-kit": "^0.31.8", "knip": "^6.12.1", "postcss": "^8.5.8", + "portless": "^0.15.1", "tailwindcss": "^4", "typescript": "^5" } diff --git a/apps/dashboard/src/app/api/integrations/github/callback/route.ts b/apps/dashboard/src/app/api/integrations/github/callback/route.ts index 0c97b73c7..ef1e11b43 100644 --- a/apps/dashboard/src/app/api/integrations/github/callback/route.ts +++ b/apps/dashboard/src/app/api/integrations/github/callback/route.ts @@ -1,5 +1,6 @@ import { upsertGitHubAppInstallation } from "@notra/ai/integrations/github"; import { redis } from "@notra/ai/utils/redis"; +import { getPortlessUrl } from "@notra/ai/utils/url"; import { ORPCError } from "@orpc/server"; import { type NextRequest, NextResponse } from "next/server"; import { assertOrganizationAccess } from "@/lib/auth/organization"; @@ -14,7 +15,10 @@ interface GitHubAppInstallState { export async function GET(request: NextRequest) { const baseUrl = - process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? ""; + getPortlessUrl() ?? + process.env.BETTER_AUTH_URL ?? + process.env.NEXT_PUBLIC_SITE_URL ?? + ""; try { const { searchParams } = new URL(request.url); diff --git a/apps/dashboard/src/app/api/integrations/linear/authorize/route.ts b/apps/dashboard/src/app/api/integrations/linear/authorize/route.ts index fc1cd6416..11cba14f9 100644 --- a/apps/dashboard/src/app/api/integrations/linear/authorize/route.ts +++ b/apps/dashboard/src/app/api/integrations/linear/authorize/route.ts @@ -1,4 +1,5 @@ import { redis } from "@notra/ai/utils/redis"; +import { getPortlessUrl } from "@notra/ai/utils/url"; import { ORPCError } from "@orpc/server"; import { type NextRequest, NextResponse } from "next/server"; import { assertOrganizationAccess } from "@/lib/auth/organization"; @@ -7,7 +8,10 @@ import { linearAuthorizeQuerySchema } from "@/schemas/linear"; export async function GET(request: NextRequest) { const baseUrl = - process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? ""; + getPortlessUrl() ?? + process.env.BETTER_AUTH_URL ?? + process.env.NEXT_PUBLIC_SITE_URL ?? + ""; try { const { searchParams } = new URL(request.url); diff --git a/apps/dashboard/src/app/api/integrations/linear/callback/route.ts b/apps/dashboard/src/app/api/integrations/linear/callback/route.ts index 8e817124a..a914b33e6 100644 --- a/apps/dashboard/src/app/api/integrations/linear/callback/route.ts +++ b/apps/dashboard/src/app/api/integrations/linear/callback/route.ts @@ -3,6 +3,7 @@ import { getLinearIntegrationsByOrganization, } from "@notra/ai/integrations/linear"; import { redis } from "@notra/ai/utils/redis"; +import { getPortlessUrl } from "@notra/ai/utils/url"; import { ORPCError } from "@orpc/server"; import { type NextRequest, NextResponse } from "next/server"; import { assertOrganizationAccess } from "@/lib/auth/organization"; @@ -17,7 +18,10 @@ import { buildCallbackUrl } from "@/utils/build-callback-url"; export async function GET(request: NextRequest) { const baseUrl = - process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? ""; + getPortlessUrl() ?? + process.env.BETTER_AUTH_URL ?? + process.env.NEXT_PUBLIC_SITE_URL ?? + ""; try { const { searchParams } = new URL(request.url); diff --git a/apps/dashboard/src/app/api/social-accounts/twitter/callback/route.ts b/apps/dashboard/src/app/api/social-accounts/twitter/callback/route.ts index 369937c44..049604b31 100644 --- a/apps/dashboard/src/app/api/social-accounts/twitter/callback/route.ts +++ b/apps/dashboard/src/app/api/social-accounts/twitter/callback/route.ts @@ -1,5 +1,6 @@ import { encryptToken } from "@notra/ai/crypto/token-encryption"; import { redis } from "@notra/ai/utils/redis"; +import { getPortlessUrl } from "@notra/ai/utils/url"; import { db } from "@notra/db/drizzle"; import { connectedSocialAccounts } from "@notra/db/schema"; import { and, eq } from "drizzle-orm"; @@ -33,7 +34,10 @@ interface OAuthState { export async function GET(request: NextRequest) { const baseUrl = - process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? ""; + getPortlessUrl() ?? + process.env.BETTER_AUTH_URL ?? + process.env.NEXT_PUBLIC_SITE_URL ?? + ""; try { const { searchParams } = new URL(request.url); diff --git a/apps/dashboard/src/app/api/workflows/event/route.ts b/apps/dashboard/src/app/api/workflows/event/route.ts index 9c278c24a..f281b6627 100644 --- a/apps/dashboard/src/app/api/workflows/event/route.ts +++ b/apps/dashboard/src/app/api/workflows/event/route.ts @@ -4,6 +4,7 @@ import { FEATURES } from "@notra/ai/billing/features"; import { getGitHubToolRepositoryContextByIntegrationId } from "@notra/ai/integrations/github"; import { getBaseUrl } from "@notra/ai/qstash/triggers"; import { getValidToneProfile } from "@notra/ai/schemas/tone"; +import { getPortlessUrl } from "@notra/ai/utils/url"; import { db } from "@notra/db/drizzle"; import type { PostSourceMetadata } from "@notra/db/schema"; import { @@ -767,7 +768,9 @@ export const { POST } = serve( if (notificationData.enabled && notificationData.ownerEmails.length > 0) { await context.run("send-notification-emails", async () => { const baseUrl = - process.env.BETTER_AUTH_URL ?? "https://app.usenotra.com"; + getPortlessUrl() ?? + process.env.BETTER_AUTH_URL ?? + "https://app.usenotra.com"; const contentOverviewLink = `${baseUrl}/${notificationData.organizationSlug}/content`; const createdContent = createdPosts.map((createdPost) => ({ title: createdPost.title, diff --git a/apps/dashboard/src/app/api/workflows/schedule/route.ts b/apps/dashboard/src/app/api/workflows/schedule/route.ts index f4983895d..21c95afe0 100644 --- a/apps/dashboard/src/app/api/workflows/schedule/route.ts +++ b/apps/dashboard/src/app/api/workflows/schedule/route.ts @@ -5,6 +5,7 @@ import { getGitHubToolRepositoryContextByIntegrationId } from "@notra/ai/integra import { getLinearToolContextByIntegrationId } from "@notra/ai/integrations/linear"; import { getBaseUrl, triggerScheduleNow } from "@notra/ai/qstash/triggers"; import { getValidToneProfile } from "@notra/ai/schemas/tone"; +import { getPortlessUrl } from "@notra/ai/utils/url"; import { db } from "@notra/db/drizzle"; import type { PostSourceMetadata } from "@notra/db/schema"; import { @@ -1144,7 +1145,9 @@ export const { POST } = serve( if (notificationData.enabled && notificationData.ownerEmails.length > 0) { await context.run("send-notification-emails", async () => { const baseUrl = - process.env.BETTER_AUTH_URL ?? "https://app.usenotra.com"; + getPortlessUrl() ?? + process.env.BETTER_AUTH_URL ?? + "https://app.usenotra.com"; const contentOverviewLink = `${baseUrl}/${notificationData.organizationSlug}/content`; const createdContent = createdPosts.map((createdPost) => ({ title: createdPost.title, diff --git a/apps/dashboard/src/lib/auth/server.ts b/apps/dashboard/src/lib/auth/server.ts index 8827bf8bd..bfc7d00b2 100644 --- a/apps/dashboard/src/lib/auth/server.ts +++ b/apps/dashboard/src/lib/auth/server.ts @@ -2,6 +2,7 @@ import { oauthProvider } from "@better-auth/oauth-provider"; import { autumn } from "@notra/ai/billing/autumn"; import { FEATURES } from "@notra/ai/billing/features"; import { redis } from "@notra/ai/utils/redis"; +import { getPortlessUrl } from "@notra/ai/utils/url"; import { db } from "@notra/db/drizzle"; import { members, organizations, sessions } from "@notra/db/schema"; import type { CheckResponse } from "autumn-js"; @@ -143,6 +144,7 @@ function getTrustedOrigins() { [ "http://localhost:3000", "https://app.usenotra.com", + getPortlessUrl(), process.env.BETTER_AUTH_URL, process.env.NEXT_PUBLIC_APP_URL, process.env.WORKFLOW_BASE_URL, @@ -257,7 +259,7 @@ export const auth = betterAuth({ }), organization({ sendInvitationEmail: async (data) => { - const inviteLink = `${process.env.BETTER_AUTH_URL}/invitation/${data.id}`; + const inviteLink = `${getPortlessUrl() ?? process.env.BETTER_AUTH_URL}/invitation/${data.id}`; await sendInviteEmailAction({ inviteeEmail: data.email, inviterName: data.inviter.user.name, @@ -415,7 +417,7 @@ export const auth = betterAuth({ storeSessionInDatabase: true, preserveSessionInDatabase: true, }, - baseURL: process.env.BETTER_AUTH_URL, + baseURL: getPortlessUrl() ?? process.env.BETTER_AUTH_URL, emailAndPassword: { enabled: true, sendResetPassword: async ({ user, url }) => { diff --git a/apps/dashboard/src/lib/email/send.ts b/apps/dashboard/src/lib/email/send.ts index f61301e15..5e390953a 100644 --- a/apps/dashboard/src/lib/email/send.ts +++ b/apps/dashboard/src/lib/email/send.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { getPortlessUrl } from "@notra/ai/utils/url"; import { AiCreditsDepletedEmail } from "@notra/email/emails/ai-credits-depleted"; import { FeedbackEmail } from "@notra/email/emails/feedback"; import { InviteUserEmail } from "@notra/email/emails/invite"; @@ -245,7 +246,9 @@ export async function sendScheduledContentFailedEmail( subject, }: SendScheduledContentFailedEmailProps ) { - const settingsLink = `${process.env.BETTER_AUTH_URL ?? "https://app.usenotra.com"}/${organizationSlug}/schedules`; + const appUrl = + getPortlessUrl() ?? process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl(); + const settingsLink = `${appUrl}/${organizationSlug}/schedules`; return sendWithRetry( resend, @@ -279,7 +282,9 @@ export async function sendScheduledContentSkippedEmail( subject, }: SendScheduledContentSkippedEmailProps ) { - const settingsLink = `${process.env.BETTER_AUTH_URL ?? "https://app.usenotra.com"}/${organizationSlug}/schedules`; + const appUrl = + getPortlessUrl() ?? process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl(); + const settingsLink = `${appUrl}/${organizationSlug}/schedules`; return sendWithRetry( resend, @@ -312,7 +317,8 @@ export async function sendAiCreditsDepletedEmail( subject, }: SendAiCreditsDepletedEmailProps ) { - const appUrl = process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl(); + const appUrl = + getPortlessUrl() ?? process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl(); const creditsLink = `${appUrl}/${organizationSlug}/settings/credits`; const idempotencyKey = createHash("sha256") .update( @@ -352,7 +358,8 @@ export async function sendWorkflowPausedEmail( subject, }: SendWorkflowPausedEmailProps ) { - const appUrl = process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl(); + const appUrl = + getPortlessUrl() ?? process.env.BETTER_AUTH_URL ?? EMAIL_CONFIG.getAppUrl(); const settingsLink = `${appUrl}/${organizationSlug}/automation/schedules`; const idempotencyKey = createHash("sha256") .update( diff --git a/apps/dashboard/src/lib/orpc/client.ts b/apps/dashboard/src/lib/orpc/client.ts index ec09acf88..b7b413eb4 100644 --- a/apps/dashboard/src/lib/orpc/client.ts +++ b/apps/dashboard/src/lib/orpc/client.ts @@ -1,3 +1,4 @@ +import { getPortlessUrl } from "@notra/ai/utils/url"; import { createORPCClient } from "@orpc/client"; import { RPCLink } from "@orpc/client/fetch"; import type { RouterClient } from "@orpc/server"; @@ -9,6 +10,7 @@ function getBaseUrl() { } return ( + getPortlessUrl() ?? process.env.NEXT_PUBLIC_SITE_URL ?? process.env.BETTER_AUTH_URL ?? "http://localhost:3000" diff --git a/apps/dashboard/src/lib/orpc/routers/social-accounts.ts b/apps/dashboard/src/lib/orpc/routers/social-accounts.ts index 14f37e522..6b8ecd6f2 100644 --- a/apps/dashboard/src/lib/orpc/routers/social-accounts.ts +++ b/apps/dashboard/src/lib/orpc/routers/social-accounts.ts @@ -1,4 +1,5 @@ import { redis } from "@notra/ai/utils/redis"; +import { getPortlessUrl } from "@notra/ai/utils/url"; import { db } from "@notra/db/drizzle"; import { connectedSocialAccounts } from "@notra/db/schema"; import { and, eq } from "drizzle-orm"; @@ -127,7 +128,9 @@ export const socialAccountsRouter = { } const baseUrl = - process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_SITE_URL; + getPortlessUrl() ?? + process.env.BETTER_AUTH_URL ?? + process.env.NEXT_PUBLIC_SITE_URL; if (!baseUrl) { throw badRequest("Application base URL is not configured"); } diff --git a/apps/docs/package.json b/apps/docs/package.json index 267067f69..30e711334 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -2,11 +2,17 @@ "name": "docs", "type": "module", "scripts": { - "dev": "mint dev --port 3005 --no-open", + "dev": "portless", + "dev:app": "mint dev --port ${PORT:-3005} --no-open", "open": "mint dev --port 3005", "openapi:check": "mint openapi-check https://api.usenotra.com/openapi.json" }, + "portless": { + "name": "docs.notra", + "script": "dev:app" + }, "devDependencies": { - "mint": "^4.2.536" + "mint": "^4.2.536", + "portless": "^0.15.1" } } diff --git a/apps/web/package.json b/apps/web/package.json index e9747f24a..bb529cd4d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,11 +3,16 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --port 3001", + "dev": "portless", + "dev:app": "next dev --port ${PORT:-3001}", "build": "next build", "start": "next start", "knip": "knip" }, + "portless": { + "name": "notra", + "script": "dev:app" + }, "dependencies": { "@bydefault/vercel": "^0.1.4", "@c15t/nextjs": "^2.0.4", @@ -57,6 +62,7 @@ "@types/react-dom": "^19.2.3", "babel-plugin-react-compiler": "1.0.0", "knip": "^6.12.1", + "portless": "^0.15.1", "tailwindcss": "^4", "typescript": "^5" }, diff --git a/bun.lock b/bun.lock index 791f4bc22..700c95c77 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "@notra/typescript-config": "workspace:*", "@types/sanitize-html": "^2.16.0", "knip": "^6.12.1", + "portless": "^0.15.1", "typescript": "^5", }, }, @@ -148,6 +149,7 @@ "babel-plugin-react-compiler": "^1.0.0", "drizzle-kit": "^0.31.8", "knip": "^6.12.1", + "portless": "^0.15.1", "postcss": "^8.5.8", "tailwindcss": "^4", "typescript": "^5", @@ -157,6 +159,7 @@ "name": "docs", "devDependencies": { "mint": "^4.2.536", + "portless": "^0.15.1", }, }, "apps/web": { @@ -211,6 +214,7 @@ "@types/react-dom": "^19.2.3", "babel-plugin-react-compiler": "1.0.0", "knip": "^6.12.1", + "portless": "^0.15.1", "tailwindcss": "^4", "typescript": "^5", }, @@ -299,6 +303,7 @@ "@react-email/preview-server": "5.2.8", "@types/node": "22.9.0", "@types/react": "^19.2.10", + "portless": "^0.15.1", "react-email": "5.2.8", "typescript": "^5.6.3", }, @@ -3561,6 +3566,8 @@ "pony-cause": ["pony-cause@1.1.1", "", {}, "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g=="], + "portless": ["portless@0.15.1", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-MQTt405HrcIa3m9OSKpH6WjJDbfNP4oFMNg0VzAoUO4B19l1eFJBFcSWfQY8KYMhzZVfa+Gea2OUrlDm8+/M4A=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="], diff --git a/packages/ai/src/utils/url.ts b/packages/ai/src/utils/url.ts index 6e1c5c27a..843afec84 100644 --- a/packages/ai/src/utils/url.ts +++ b/packages/ai/src/utils/url.ts @@ -4,6 +4,14 @@ export function normalizeUrl(url: string): string { return url.replace(TRAILING_SLASHES_REGEX, ""); } +export function getPortlessUrl(): string | undefined { + if (process.env.NODE_ENV !== "development" || !process.env.PORTLESS_URL) { + return undefined; + } + + return normalizeUrl(process.env.PORTLESS_URL); +} + export function getConfiguredWorkflowUrl(): string | undefined { const url = process.env.WORKFLOW_BASE_URL; @@ -16,6 +24,7 @@ export function getConfiguredWorkflowUrl(): string | undefined { export function getConfiguredAppUrl(): string | undefined { const url = + getPortlessUrl() || process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined); diff --git a/packages/email/package.json b/packages/email/package.json index 9cc659cf8..5a52a3354 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -25,7 +25,12 @@ "./utils/resend": "./src/utils/resend.ts" }, "scripts": { - "dev": "email dev --dir ./src/emails --port 3002" + "dev": "portless", + "dev:app": "email dev --dir ./src/emails --port ${PORT:-3002}" + }, + "portless": { + "name": "email.notra", + "script": "dev:app" }, "dependencies": { "@react-email/components": "1.0.8", @@ -37,6 +42,7 @@ "@react-email/preview-server": "5.2.8", "@types/node": "22.9.0", "@types/react": "^19.2.10", + "portless": "^0.15.1", "react-email": "5.2.8", "typescript": "^5.6.3" } diff --git a/turbo.json b/turbo.json index e12571f96..86c597e30 100644 --- a/turbo.json +++ b/turbo.json @@ -20,6 +20,7 @@ "GOOGLE_CLIENT_SECRET", "INTEGRATION_ENCRYPTION_KEY", "OPENROUTER_API_KEY", + "PORTLESS_URL", "QSTASH_URL", "QSTASH_CURRENT_SIGNING_KEY", "QSTASH_NEXT_SIGNING_KEY", @@ -75,6 +76,7 @@ }, "dev": { "cache": false, + "passThroughEnv": ["PORT", "PORTLESS", "PORTLESS_*"], "persistent": true } }