diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..884e2c1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.git +.env* +*.md +tests/ +.storybook/ +storybook-static/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..71cb16f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Build-time args for NEXT_PUBLIC_ vars and Sentry source maps +ARG NEXT_PUBLIC_IDP_BASE_URL +ARG SENTRY_ORG +ARG SENTRY_PROJECT +ARG SENTRY_AUTH_TOKEN + +ENV NEXT_PUBLIC_IDP_BASE_URL=$NEXT_PUBLIC_IDP_BASE_URL +ENV SENTRY_ORG=$SENTRY_ORG +ENV SENTRY_PROJECT=$SENTRY_PROJECT +ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN + +# Dummy DATABASE_URL for build — Next.js compiles server components which +# import lib/database.ts, but it doesn't actually connect during build. +ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" + +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app + +RUN adduser -D -u 1000 appuser + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=appuser:appuser /app/.next/standalone ./ +COPY --from=builder --chown=appuser:appuser /app/.next/static ./.next/static + +USER appuser +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/app/layout.tsx b/app/layout.tsx index 7dab1a1..b26ce8d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,7 +4,7 @@ import "./globals.css"; import { NavBar } from "@/components/navbar"; import { ThemeProvider } from "next-themes"; import { Toaster } from "@/components/ui/toaster"; -import { Analytics } from "@vercel/analytics/react"; + import { StatusIndicatorStack } from "@/components/status-indicators"; const inter = Inter({ subsets: ["latin"] }); @@ -28,7 +28,7 @@ export default function RootLayout({
{children}
- + ); diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 0000000..1648ad4 --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,56 @@ +options: + logging: CLOUD_LOGGING_ONLY + +substitutions: + _NEXT_PUBLIC_IDP_BASE_URL_PROD: 'https://identity.ethanswan.com' + _NEXT_PUBLIC_IDP_BASE_URL_STAGING: 'https://identity-staging.tailc06f30.ts.net' + _SENTRY_ORG: 'forecasting' + _SENTRY_PROJECT: 'forecasting-app' + +steps: + # Build prod image + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '--build-arg' + - 'NEXT_PUBLIC_IDP_BASE_URL=${_NEXT_PUBLIC_IDP_BASE_URL_PROD}' + - '--build-arg' + - 'SENTRY_ORG=${_SENTRY_ORG}' + - '--build-arg' + - 'SENTRY_PROJECT=${_SENTRY_PROJECT}' + - '--build-arg' + - 'SENTRY_AUTH_TOKEN=${_SENTRY_AUTH_TOKEN}' + - '-t' + - 'us-central1-docker.pkg.dev/ethans-services/containers/forecasting:${SHORT_SHA}-prod' + - '-t' + - 'us-central1-docker.pkg.dev/ethans-services/containers/forecasting:prod' + - '.' + # Build staging image + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '--build-arg' + - 'NEXT_PUBLIC_IDP_BASE_URL=${_NEXT_PUBLIC_IDP_BASE_URL_STAGING}' + - '--build-arg' + - 'SENTRY_ORG=${_SENTRY_ORG}' + - '--build-arg' + - 'SENTRY_PROJECT=${_SENTRY_PROJECT}' + - '--build-arg' + - 'SENTRY_AUTH_TOKEN=${_SENTRY_AUTH_TOKEN}' + - '-t' + - 'us-central1-docker.pkg.dev/ethans-services/containers/forecasting:${SHORT_SHA}-staging' + - '-t' + - 'us-central1-docker.pkg.dev/ethans-services/containers/forecasting:staging' + - '.' + # Push all tags + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - '--all-tags' + - 'us-central1-docker.pkg.dev/ethans-services/containers/forecasting' + +images: + - 'us-central1-docker.pkg.dev/ethans-services/containers/forecasting:${SHORT_SHA}-prod' + - 'us-central1-docker.pkg.dev/ethans-services/containers/forecasting:prod' + - 'us-central1-docker.pkg.dev/ethans-services/containers/forecasting:${SHORT_SHA}-staging' + - 'us-central1-docker.pkg.dev/ethans-services/containers/forecasting:staging' diff --git a/instrumentation.ts b/instrumentation.ts index 9bd6e21..aef11da 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,8 +1,8 @@ import * as Sentry from "@sentry/nextjs"; export async function register() { - // Load environment configuration first, but only in Node.js runtime and outside of Vercel (the deployment environment) - if (process.env.NEXT_RUNTIME === "nodejs" && !(process.env.VERCEL === "1")) { + // Load environment configuration first, but only in Node.js runtime and outside of K8s (where env vars come from configmaps/secrets) + if (process.env.NEXT_RUNTIME === "nodejs" && !(process.env.K8S === "1")) { const { loadEnvironment } = await import("./lib/environment"); loadEnvironment(); await import("./sentry.server.config"); diff --git a/k8s/argocd/applications.yaml b/k8s/argocd/applications.yaml new file mode 100644 index 0000000..eef5335 --- /dev/null +++ b/k8s/argocd/applications.yaml @@ -0,0 +1,41 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: forecasting-staging + namespace: argocd + annotations: + argocd-image-updater.argoproj.io/image-list: forecasting=us-central1-docker.pkg.dev/ethans-services/containers/forecasting + argocd-image-updater.argoproj.io/forecasting.update-strategy: newest-build + argocd-image-updater.argoproj.io/forecasting.allow-tags: "regexp:^[a-f0-9]+-staging$" +spec: + project: default + source: + repoURL: https://github.com/eswan18/forecasting + targetRevision: main + path: k8s/staging + destination: + server: https://kubernetes.default.svc + namespace: forecasting-staging + syncPolicy: + automated: + prune: true + selfHeal: true +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: forecasting-prod + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/eswan18/forecasting + targetRevision: main + path: k8s/prod + destination: + server: https://kubernetes.default.svc + namespace: forecasting-prod + syncPolicy: + automated: + prune: true + selfHeal: true diff --git a/k8s/argocd/image-updater.yaml b/k8s/argocd/image-updater.yaml new file mode 100644 index 0000000..5891e3f --- /dev/null +++ b/k8s/argocd/image-updater.yaml @@ -0,0 +1,16 @@ +apiVersion: argocd-image-updater.argoproj.io/v1alpha1 +kind: ImageUpdater +metadata: + name: forecasting-staging + namespace: argocd +spec: + namespace: argocd + writeBackConfig: + method: argocd + applicationRefs: + - namePattern: forecasting-staging + images: + - alias: forecasting + imageName: us-central1-docker.pkg.dev/ethans-services/containers/forecasting + commonUpdateSettings: + updateStrategy: newest-build diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml new file mode 100644 index 0000000..49e2ca1 --- /dev/null +++ b/k8s/base/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: forecasting-config +data: + K8S: "1" + NODE_ENV: "production" + SENTRY_ORG: "forecasting" + SENTRY_PROJECT: "forecasting-app" diff --git a/k8s/base/deployment.yaml b/k8s/base/deployment.yaml new file mode 100644 index 0000000..39e4ca0 --- /dev/null +++ b/k8s/base/deployment.yaml @@ -0,0 +1,63 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: forecasting +spec: + replicas: 2 + selector: + matchLabels: + app: forecasting + template: + metadata: + labels: + app: forecasting + spec: + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app: forecasting + tolerations: + - key: cloud.google.com/gke-spot + operator: Equal + value: "true" + effect: NoSchedule + containers: + - name: forecasting + image: us-central1-docker.pkg.dev/ethans-services/containers/forecasting:latest + ports: + - containerPort: 3000 + readinessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 30 + resources: + requests: + memory: "128Mi" + cpu: "5m" + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - name: secrets-store + mountPath: "/mnt/secrets" + readOnly: true + volumes: + - name: secrets-store + csi: + driver: secrets-store.csi.k8s.io + readOnly: true diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 0000000..8d19601 --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml + - configmap.yaml diff --git a/k8s/base/service.yaml b/k8s/base/service.yaml new file mode 100644 index 0000000..1039952 --- /dev/null +++ b/k8s/base/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: forecasting +spec: + selector: + app: forecasting + ports: + - port: 80 + targetPort: 3000 + type: ClusterIP diff --git a/k8s/prod/configmap-env.yaml b/k8s/prod/configmap-env.yaml new file mode 100644 index 0000000..00f17f8 --- /dev/null +++ b/k8s/prod/configmap-env.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: forecasting-prod-env-config +data: + ENV: "prod" + IDP_BASE_URL: "http://identity.identity-prod.svc.cluster.local" + SENTRY_DSN: "https://42fb7fde7d5842831f2324ed33c7f50f@o4509062063587328.ingest.us.sentry.io/4509062066012160" diff --git a/k8s/prod/deployment-patch.yaml b/k8s/prod/deployment-patch.yaml new file mode 100644 index 0000000..dd94931 --- /dev/null +++ b/k8s/prod/deployment-patch.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: forecasting +spec: + replicas: 2 + template: + spec: + serviceAccountName: forecasting-prod-ksa + containers: + - name: forecasting + envFrom: + - configMapRef: + name: forecasting-config + - configMapRef: + name: forecasting-prod-env-config + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: forecasting-prod-secrets + key: DATABASE_URL + - name: ADMIN_DATABASE_URL + valueFrom: + secretKeyRef: + name: forecasting-prod-secrets + key: ADMIN_DATABASE_URL + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: forecasting-prod-secrets + key: JWT_SECRET + - name: ARGON2_SALT + valueFrom: + secretKeyRef: + name: forecasting-prod-secrets + key: ARGON2_SALT + - name: IDP_CLIENT_ID + valueFrom: + secretKeyRef: + name: forecasting-prod-secrets + key: IDP_CLIENT_ID + - name: IDP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: forecasting-prod-secrets + key: IDP_CLIENT_SECRET + - name: IDP_ADMIN_CLIENT_ID + valueFrom: + secretKeyRef: + name: forecasting-prod-secrets + key: IDP_ADMIN_CLIENT_ID + - name: IDP_ADMIN_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: forecasting-prod-secrets + key: IDP_ADMIN_CLIENT_SECRET + volumes: + - name: secrets-store + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: forecasting-prod-secrets diff --git a/k8s/prod/kustomization.yaml b/k8s/prod/kustomization.yaml new file mode 100644 index 0000000..019bc57 --- /dev/null +++ b/k8s/prod/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: forecasting-prod +resources: + - ../base + - namespace.yaml + - service-account.yaml + - configmap-env.yaml + - secret-provider-class.yaml +patches: + - path: deployment-patch.yaml diff --git a/k8s/prod/namespace.yaml b/k8s/prod/namespace.yaml new file mode 100644 index 0000000..c306ace --- /dev/null +++ b/k8s/prod/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: forecasting-prod diff --git a/k8s/prod/secret-provider-class.yaml b/k8s/prod/secret-provider-class.yaml new file mode 100644 index 0000000..95d10a5 --- /dev/null +++ b/k8s/prod/secret-provider-class.yaml @@ -0,0 +1,45 @@ +apiVersion: secrets-store.csi.x-k8s.io/v1 +kind: SecretProviderClass +metadata: + name: forecasting-prod-secrets + namespace: forecasting-prod +spec: + provider: gcp + parameters: + secrets: | + - resourceName: "projects/ethans-services/secrets/forecasting_prod_database_url/versions/latest" + path: "DATABASE_URL" + - resourceName: "projects/ethans-services/secrets/forecasting_prod_admin_database_url/versions/latest" + path: "ADMIN_DATABASE_URL" + - resourceName: "projects/ethans-services/secrets/forecasting_prod_jwt_secret/versions/latest" + path: "JWT_SECRET" + - resourceName: "projects/ethans-services/secrets/forecasting_prod_argon2_salt/versions/latest" + path: "ARGON2_SALT" + - resourceName: "projects/ethans-services/secrets/forecasting_prod_idp_client_id/versions/latest" + path: "IDP_CLIENT_ID" + - resourceName: "projects/ethans-services/secrets/forecasting_prod_idp_client_secret/versions/latest" + path: "IDP_CLIENT_SECRET" + - resourceName: "projects/ethans-services/secrets/forecasting_prod_idp_admin_client_id/versions/latest" + path: "IDP_ADMIN_CLIENT_ID" + - resourceName: "projects/ethans-services/secrets/forecasting_prod_idp_admin_client_secret/versions/latest" + path: "IDP_ADMIN_CLIENT_SECRET" + secretObjects: + - secretName: forecasting-prod-secrets + type: Opaque + data: + - objectName: "DATABASE_URL" + key: DATABASE_URL + - objectName: "ADMIN_DATABASE_URL" + key: ADMIN_DATABASE_URL + - objectName: "JWT_SECRET" + key: JWT_SECRET + - objectName: "ARGON2_SALT" + key: ARGON2_SALT + - objectName: "IDP_CLIENT_ID" + key: IDP_CLIENT_ID + - objectName: "IDP_CLIENT_SECRET" + key: IDP_CLIENT_SECRET + - objectName: "IDP_ADMIN_CLIENT_ID" + key: IDP_ADMIN_CLIENT_ID + - objectName: "IDP_ADMIN_CLIENT_SECRET" + key: IDP_ADMIN_CLIENT_SECRET diff --git a/k8s/prod/service-account.yaml b/k8s/prod/service-account.yaml new file mode 100644 index 0000000..b3f8909 --- /dev/null +++ b/k8s/prod/service-account.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: forecasting-prod-ksa + namespace: forecasting-prod + annotations: + iam.gke.io/gcp-service-account: forecasting-prod-sa@ethans-services.iam.gserviceaccount.com diff --git a/k8s/staging/configmap-env.yaml b/k8s/staging/configmap-env.yaml new file mode 100644 index 0000000..eda0f7d --- /dev/null +++ b/k8s/staging/configmap-env.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: forecasting-staging-env-config +data: + ENV: "staging" + IDP_BASE_URL: "http://identity.identity-staging.svc.cluster.local" + SENTRY_DSN: "https://42fb7fde7d5842831f2324ed33c7f50f@o4509062063587328.ingest.us.sentry.io/4509062066012160" diff --git a/k8s/staging/deployment-patch.yaml b/k8s/staging/deployment-patch.yaml new file mode 100644 index 0000000..9d09904 --- /dev/null +++ b/k8s/staging/deployment-patch.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: forecasting +spec: + replicas: 1 + template: + spec: + serviceAccountName: forecasting-staging-ksa + containers: + - name: forecasting + envFrom: + - configMapRef: + name: forecasting-config + - configMapRef: + name: forecasting-staging-env-config + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: forecasting-staging-secrets + key: DATABASE_URL + - name: ADMIN_DATABASE_URL + valueFrom: + secretKeyRef: + name: forecasting-staging-secrets + key: ADMIN_DATABASE_URL + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: forecasting-staging-secrets + key: JWT_SECRET + - name: ARGON2_SALT + valueFrom: + secretKeyRef: + name: forecasting-staging-secrets + key: ARGON2_SALT + - name: IDP_CLIENT_ID + valueFrom: + secretKeyRef: + name: forecasting-staging-secrets + key: IDP_CLIENT_ID + - name: IDP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: forecasting-staging-secrets + key: IDP_CLIENT_SECRET + - name: IDP_ADMIN_CLIENT_ID + valueFrom: + secretKeyRef: + name: forecasting-staging-secrets + key: IDP_ADMIN_CLIENT_ID + - name: IDP_ADMIN_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: forecasting-staging-secrets + key: IDP_ADMIN_CLIENT_SECRET + volumes: + - name: secrets-store + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: forecasting-staging-secrets diff --git a/k8s/staging/kustomization.yaml b/k8s/staging/kustomization.yaml new file mode 100644 index 0000000..afdae45 --- /dev/null +++ b/k8s/staging/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: forecasting-staging +resources: + - ../base + - namespace.yaml + - service-account.yaml + - configmap-env.yaml + - secret-provider-class.yaml + - tailscale-ingress.yaml +patches: + - path: deployment-patch.yaml diff --git a/k8s/staging/namespace.yaml b/k8s/staging/namespace.yaml new file mode 100644 index 0000000..dfa94af --- /dev/null +++ b/k8s/staging/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: forecasting-staging diff --git a/k8s/staging/secret-provider-class.yaml b/k8s/staging/secret-provider-class.yaml new file mode 100644 index 0000000..58c180e --- /dev/null +++ b/k8s/staging/secret-provider-class.yaml @@ -0,0 +1,44 @@ +apiVersion: secrets-store.csi.x-k8s.io/v1 +kind: SecretProviderClass +metadata: + name: forecasting-staging-secrets +spec: + provider: gcp + parameters: + secrets: | + - resourceName: "projects/ethans-services/secrets/forecasting_staging_database_url/versions/latest" + path: "DATABASE_URL" + - resourceName: "projects/ethans-services/secrets/forecasting_staging_admin_database_url/versions/latest" + path: "ADMIN_DATABASE_URL" + - resourceName: "projects/ethans-services/secrets/forecasting_staging_jwt_secret/versions/latest" + path: "JWT_SECRET" + - resourceName: "projects/ethans-services/secrets/forecasting_staging_argon2_salt/versions/latest" + path: "ARGON2_SALT" + - resourceName: "projects/ethans-services/secrets/forecasting_staging_idp_client_id/versions/latest" + path: "IDP_CLIENT_ID" + - resourceName: "projects/ethans-services/secrets/forecasting_staging_idp_client_secret/versions/latest" + path: "IDP_CLIENT_SECRET" + - resourceName: "projects/ethans-services/secrets/forecasting_staging_idp_admin_client_id/versions/latest" + path: "IDP_ADMIN_CLIENT_ID" + - resourceName: "projects/ethans-services/secrets/forecasting_staging_idp_admin_client_secret/versions/latest" + path: "IDP_ADMIN_CLIENT_SECRET" + secretObjects: + - secretName: forecasting-staging-secrets + type: Opaque + data: + - objectName: "DATABASE_URL" + key: DATABASE_URL + - objectName: "ADMIN_DATABASE_URL" + key: ADMIN_DATABASE_URL + - objectName: "JWT_SECRET" + key: JWT_SECRET + - objectName: "ARGON2_SALT" + key: ARGON2_SALT + - objectName: "IDP_CLIENT_ID" + key: IDP_CLIENT_ID + - objectName: "IDP_CLIENT_SECRET" + key: IDP_CLIENT_SECRET + - objectName: "IDP_ADMIN_CLIENT_ID" + key: IDP_ADMIN_CLIENT_ID + - objectName: "IDP_ADMIN_CLIENT_SECRET" + key: IDP_ADMIN_CLIENT_SECRET diff --git a/k8s/staging/service-account.yaml b/k8s/staging/service-account.yaml new file mode 100644 index 0000000..f6a8132 --- /dev/null +++ b/k8s/staging/service-account.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: forecasting-staging-ksa + namespace: forecasting-staging + annotations: + iam.gke.io/gcp-service-account: forecasting-staging-sa@ethans-services.iam.gserviceaccount.com diff --git a/k8s/staging/tailscale-ingress.yaml b/k8s/staging/tailscale-ingress.yaml new file mode 100644 index 0000000..7778d60 --- /dev/null +++ b/k8s/staging/tailscale-ingress.yaml @@ -0,0 +1,14 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: forecasting-staging-ts +spec: + defaultBackend: + service: + name: forecasting + port: + number: 80 + ingressClassName: tailscale + tls: + - hosts: + - forecasting-staging diff --git a/lib/idp/client.test.ts b/lib/idp/client.test.ts index 1ae6485..a8b591a 100644 --- a/lib/idp/client.test.ts +++ b/lib/idp/client.test.ts @@ -13,6 +13,8 @@ vi.mock("jose", () => ({ describe("IDP Client", () => { beforeEach(() => { vi.resetModules(); + vi.stubEnv("IDP_BASE_URL", "https://identity.example.com"); + vi.stubEnv("NEXT_PUBLIC_IDP_BASE_URL", "https://identity.example.com"); }); describe("buildNameFromUserInfo", () => { diff --git a/lib/idp/client.ts b/lib/idp/client.ts index 7926ff4..5b18f4e 100644 --- a/lib/idp/client.ts +++ b/lib/idp/client.ts @@ -1,8 +1,16 @@ import "server-only"; import * as jose from "jose"; -// IDP Configuration -const IDP_BASE_URL = process.env.IDP_BASE_URL || "https://identity.ethanswan.com"; +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing required environment variable: ${name}`); + return value; +} + +// IDP_BASE_URL: server-to-server calls (internal K8s URL in cluster). +// IDP_PUBLIC_URL: browser-facing redirects and JWT issuer validation (external URL, baked at build time via NEXT_PUBLIC_IDP_BASE_URL). +const IDP_BASE_URL = requiredEnv("IDP_BASE_URL"); +const IDP_PUBLIC_URL = requiredEnv("NEXT_PUBLIC_IDP_BASE_URL"); const IDP_CLIENT_ID = process.env.IDP_CLIENT_ID || ""; const IDP_CLIENT_SECRET = process.env.IDP_CLIENT_SECRET || ""; const IDP_ADMIN_CLIENT_ID = process.env.IDP_ADMIN_CLIENT_ID || ""; @@ -184,7 +192,7 @@ export function getAuthorizationUrl( code_challenge_method: "S256", }); - return `${IDP_BASE_URL}/oauth/authorize?${params.toString()}`; + return `${IDP_PUBLIC_URL}/oauth/authorize?${params.toString()}`; } /** @@ -279,7 +287,7 @@ export async function validateIDPToken(token: string): Promise { const jwks = await getJWKS(); const { payload } = await jose.jwtVerify(token, jwks, { - issuer: IDP_BASE_URL, + issuer: IDP_PUBLIC_URL, // We don't strictly validate audience since it varies by client }); diff --git a/next.config.mjs b/next.config.mjs index 690e4fb..1c45e35 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,7 @@ import { withSentryConfig } from "@sentry/nextjs"; /** @type {import('next').NextConfig} */ const nextConfig = { + output: "standalone", images: { remotePatterns: [ { @@ -35,10 +36,4 @@ export default withSentryConfig(nextConfig, { // Automatically tree-shake Sentry logger statements to reduce bundle size disableLogger: true, - - // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) - // See the following for more information: - // https://docs.sentry.io/product/crons/ - // https://vercel.com/docs/cron-jobs - automaticVercelMonitors: true, }); diff --git a/package-lock.json b/package-lock.json index 4c134d7..85356fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,6 @@ "@radix-ui/react-tooltip": "^1.2.6", "@sentry/nextjs": "^10.26.0", "@tanstack/react-table": "^8.21.3", - "@vercel/analytics": "^1.5.0", "argon2": "^0.44.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -6321,44 +6320,6 @@ "win32" ] }, - "node_modules/@vercel/analytics": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", - "integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==", - "license": "MPL-2.0", - "peerDependencies": { - "@remix-run/react": "^2", - "@sveltejs/kit": "^1 || ^2", - "next": ">= 13", - "react": "^18 || ^19 || ^19.0.0-rc", - "svelte": ">= 4", - "vue": "^3", - "vue-router": "^4" - }, - "peerDependenciesMeta": { - "@remix-run/react": { - "optional": true - }, - "@sveltejs/kit": { - "optional": true - }, - "next": { - "optional": true - }, - "react": { - "optional": true - }, - "svelte": { - "optional": true - }, - "vue": { - "optional": true - }, - "vue-router": { - "optional": true - } - } - }, "node_modules/@vitest/browser": { "version": "4.0.17", "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.17.tgz", diff --git a/package.json b/package.json index 487e2c4..5822791 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "@radix-ui/react-tooltip": "^1.2.6", "@sentry/nextjs": "^10.26.0", "@tanstack/react-table": "^8.21.3", - "@vercel/analytics": "^1.5.0", "argon2": "^0.44.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1",