From 65ae7675ba994bebfb16038147cec2bf608dcb0f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 16 Jul 2026 22:01:36 -0500 Subject: [PATCH 01/14] Complete docker build in preparation for app-runner deployment --- .dockerignore | 8 ++ .env.example | 5 +- Dockerfile | 53 ++++++++++++ deploy.md | 12 --- ...talocean-database.crt => digital-ocean.crt | 0 nixos/module.nix | 81 ------------------- package-lock.json | 3 + package.json | 5 +- src/lib/auth.ts | 8 +- src/lib/db.ts | 13 +-- vite.config.ts | 9 +++ 11 files changed, 88 insertions(+), 109 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 deploy.md rename digitalocean-database.crt => digital-ocean.crt (100%) delete mode 100644 nixos/module.nix diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..be97dc3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.svelte-kit +build +.git +.env +.env.* +npm-debug.log* +.DS_Store \ No newline at end of file diff --git a/.env.example b/.env.example index 99dc697..9fe22a7 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ DATABASE_URL= BETTER_AUTH_SECRET= BETTER_AUTH_URL= -GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_ID=6 GOOGLE_CLIENT_SECRET= +RESEND_API_KEY= +EMAIL_FROM= +RESEND_TEMPLATE_ID= \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94784d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# --- Build stage --- +FROM node:24-slim AS build +WORKDIR /app + +# Placeholder values ONLY so `vite build` doesn't crash on $env/static/* +# imports during the build step. These are NOT the real runtime values — +# App Platform injects the real ones as env vars when the container runs. +ARG DATABASE_URL=postgres://placeholder:placeholder@localhost:5432/placeholder +ARG ORIGIN=http://localhost:3000 +ARG BETTER_AUTH_URL=http://localhost:3000 +ARG BETTER_AUTH_BASE_URL=http://localhost:3000 +ARG BETTER_AUTH_SECRET=placeholder-build-time-secret-not-used-at-runtime +ENV DATABASE_URL=$DATABASE_URL +ENV ORIGIN=$ORIGIN +ENV BETTER_AUTH_URL=$BETTER_AUTH_URL +ENV BETTER_AUTH_BASE_URL=$BETTER_AUTH_BASE_URL +ENV BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Drop devDependencies now that the build is done +RUN npm prune --omit=dev + +# --- Runtime stage --- +FROM node:24-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 + +# Install the DigitalOcean managed DB CA cert into the system trust store. +# `ca-certificates` provides update-ca-certificates; certs placed in +# /usr/local/share/ca-certificates/ must end in .crt to be picked up. +COPY digital-ocean.crt /usr/local/share/ca-certificates/digital-ocean.crt +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Node still doesn't read the system trust store unless --use-system-ca is +# passed explicitly (available on 22.19+/24.6+, but off by default even here). +# NODE_EXTRA_CA_CERTS is simpler and works regardless of Node version. +ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/digital-ocean.crt + +COPY --from=build /app/build ./build +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/package.json ./package.json + +EXPOSE 3000 +CMD ["node", "build/index.js"] \ No newline at end of file diff --git a/deploy.md b/deploy.md deleted file mode 100644 index 0c50fcd..0000000 --- a/deploy.md +++ /dev/null @@ -1,12 +0,0 @@ -Initial Setup: -1. Add the portal flake input to Webserver-Infra/flake.nix with a Forgejo access token. -- Does this need to be in just the inputs, or the outputs as well? I'm still very much learning nix and these things get confusing. -2. Add services.portal.enable = true; to Webserver-Infra/configuration.nix -3. Remove the existing users.users.portal and groups because they exist in this flake now -4. Clone the repo on the server, place the .env, build, then run nixos-rebuild switch. This is mainly to start the service, I presume. The build is just so that the service has something to - start. - -To update: -1. git pull -2. Apply shell-instance environment variables for placeholder values to then run `npm ci && npm run build && npm ci --omit=dev` -3. Restart the systemctl service (portal.service) to pickup the changes because the service is in charge of the `node build` command. diff --git a/digitalocean-database.crt b/digital-ocean.crt similarity index 100% rename from digitalocean-database.crt rename to digital-ocean.crt diff --git a/nixos/module.nix b/nixos/module.nix deleted file mode 100644 index 5978065..0000000 --- a/nixos/module.nix +++ /dev/null @@ -1,81 +0,0 @@ -{ config, lib, pkgs, ... }: - -let - cfg = config.services.portal; -in -{ - options.services.portal = { - enable = lib.mkEnableOption "RhemaWave Portal"; - - directory = lib.mkOption { - type = lib.types.path; - default = "/mnt/volume_nyc3_01/Portal.RhemaWaveTech.com"; - description = "Path to the Portal git clone."; - }; - - envFile = lib.mkOption { - type = lib.types.path; - default = "${cfg.directory}/.env"; - defaultText = lib.literalExpression ''"''${cfg.directory}/.env"''; - description = "Path to the .env file containing secrets."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 3001; - description = "Port the Portal listens on."; - }; - - host = lib.mkOption { - type = lib.types.str; - default = "0.0.0.0"; - description = "Host address the Portal binds to."; - }; - }; - - config = lib.mkIf cfg.enable { - users.users.portal = { - isSystemUser = true; - group = "portal"; - home = cfg.directory; - }; - - users.groups.portal = { }; - - systemd.services.portal = { - description = "RhemaWave Portal"; - after = [ "network.target" ]; - wantedBy = [ "multi-user.target" ]; - - serviceConfig = { - Type = "simple"; - User = "portal"; - Group = "portal"; - WorkingDirectory = cfg.directory; - EnvironmentFile = cfg.envFile; - ExecStart = "${pkgs.nodejs_22}/bin/node build"; - Restart = "on-failure"; - RestartSec = 5; - }; - - environment = { - HOST = cfg.host; - PORT = toString cfg.port; - DO_CA_CERTIFICATE = "/var/lib/portal/do-ca-certificate.crt"; - NODE_ENV = "production"; - }; - }; - - security.sudo.extraRules = [ - { - users = [ "forgejo-runner" ]; - commands = [ - { - command = "/run/current-system/sw/bin/systemctl restart portal"; - options = [ "NOPASSWD" ]; - } - ]; - } - ]; - }; -} diff --git a/package-lock.json b/package-lock.json index f394660..770a0fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,9 @@ "vite": "^7.2.6", "vitest": "^4.0.15", "vitest-browser-svelte": "^2.0.1" + }, + "engines": { + "node": ">=24" } }, "node_modules/@better-auth/core": { diff --git a/package.json b/package.json index 9469967..bb33188 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.0.1", "type": "module", + "engines": { + "node": ">=24" + }, "scripts": { "dev": "vite dev", "build": "vite build", @@ -57,4 +60,4 @@ "dotenv": "^17.2.3", "pg": "^8.16.3" } -} +} \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1af3605..b646e9b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,21 +3,23 @@ import { prismaAdapter } from "better-auth/adapters/prisma"; import { prisma } from "$lib/db"; import { sveltekitCookies } from "better-auth/svelte-kit"; import { getRequestEvent } from "$app/server"; +import { env } from "$env/dynamic/private"; export const auth = betterAuth({ - baseURL: process.env.BETTER_AUTH_URL, + baseURL: env.BETTER_AUTH_URL, database: prismaAdapter(prisma, { provider: "postgresql", }), + secret: env.BETTER_AUTH_SECRET, emailAndPassword: { enabled: true, }, socialProviders: { google: { prompt: "select_account", - clientId: process.env.GOOGLE_CLIENT_ID as string, - clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET, } }, plugins: [sveltekitCookies(getRequestEvent)], diff --git a/src/lib/db.ts b/src/lib/db.ts index d33a2da..10cd777 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,23 +1,14 @@ -import 'dotenv/config'; -import fs from 'fs'; import pg from 'pg'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '$lib/generated/prisma/client'; +import { env } from '$env/dynamic/private'; let _prisma: PrismaClient | undefined; function getPrisma(): PrismaClient { if (!_prisma) { - const ca_cert = process.env.DO_CA_CERTIFICATE ?? ''; - const pool = new pg.Pool({ - connectionString: process.env.DATABASE_URL, - ssl: ca_cert - ? { - rejectUnauthorized: true, - ca: fs.readFileSync(ca_cert).toString() - } - : undefined + connectionString: env.DATABASE_URL }); const adapter = new PrismaPg(pool); diff --git a/vite.config.ts b/vite.config.ts index 5a478a5..04f79b4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,15 @@ import { sveltekit } from '@sveltejs/kit/vite'; export default defineConfig({ plugins: [tailwindcss(), sveltekit()], + build: { + rollupOptions: { + external: [ + '@prisma/client/runtime/query_compiler_bg.postgresql.mjs', + '@prisma/client/runtime/query_compiler_bg.postgresql.wasm-base64.mjs' + ] + } + }, + test: { expect: { requireAssertions: true }, From de36a6e7a0be1bf56b292efd8fba6d689da04235 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 10:23:22 -0500 Subject: [PATCH 02/14] update ci actions --- .gitea/workflows/deploy.yaml | 35 +++++++++++++++++++++++++++++++++++ README.md | 35 ++++++++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 .gitea/workflows/deploy.yaml diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..0f593c6 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,35 @@ +name: Build and Push to Digital Ocean Container Registry + +on: + push: + branches: [main] + +jobs: + build-and-push: + runs-on: actions-server + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install doctl + run: | + curl -sL https://github.com/digitalocean/doctl/releases/latest/download/doctl-linux-amd64.tar.gz | tar xz + sudo mv doctl /usr/local/bin + + - name: Auth with DOCR + run: | + doctl auth init -t "${{ secrets.DO_TOKEN }}" + doctl registry login + + - name: Build image + run: | + docker build --platform linux/amd64 \ + -t registry.digitalocean.com/radiantwave-portal-container-registry/portal:${{ gitea.sha }} \ + -t registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest \ + . + + - name: Push image + run: | + docker push registry.digitalocean.com/radiantwave-portal-container-registry/portal:${{ gitea.sha }} + docker push registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest diff --git a/README.md b/README.md index 70f0447..4415c68 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,31 @@ -# Rhema Wave Technologies - User Portal - -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +# Radiant Wave Technologies - User Portal +A combination application that combines the main Radiant Wave API with a user-facing account management application, +and an admin-facing platform management application. An all-in-one management platform for Radiant Wave and Radiant Wave Technologies. ## Deployment +This application runs inside of a Docker container, defined by the Dockerfile found within this repo. +Codeberg/Gitea actions are triggered on `push` actions to the `main` branch, and build the docker container. +The docker container is then uploaded to the Digital Ocean Container Registry, where it is pulled into an +App Platform application and run. -- Update the Authorized Redirect URIs on Google Cloud Console -- Update the Better Auth URL -- To migrate development changes to the database, run `npm run migarte -- ` -- To run deployment changes to the database, run `npm run deploy` +To build the Docker container locally (with podman), ensuring builds pass prior to pushing to `main`: + +```sh +podman build --platform linux/amd64 -t registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest . +``` + +To build the Docker container on a Linux machine, such as a Forgejo Action Runner: + +```sh +docker build -t registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest . +``` + +Then push to the container registry: + +```sh +podman push registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest +``` + +--- + +## Development \ No newline at end of file From b8eede93ead5741b0c49af74e16878b05c1cb84c Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 10:25:22 -0500 Subject: [PATCH 03/14] Change runs-on to self-hosted from actions-server --- .gitea/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 0f593c6..5e48977 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -6,7 +6,7 @@ on: jobs: build-and-push: - runs-on: actions-server + runs-on: self-hosted steps: - name: Checkout From 8939f58dca1a1c3f16e1094f6ffc6caa48ad19a8 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 10:29:10 -0500 Subject: [PATCH 04/14] Change runs-on from self-hosted to ubuntu-latest --- .gitea/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 5e48977..bf73909 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -6,7 +6,7 @@ on: jobs: build-and-push: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout From 60067251824e345bd0496b1c6e665ad489332fe2 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 10:33:43 -0500 Subject: [PATCH 05/14] Switch doctl to snap version --- .gitea/workflows/deploy.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index bf73909..9349d3b 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -13,9 +13,7 @@ jobs: uses: actions/checkout@v4 - name: Install doctl - run: | - curl -sL https://github.com/digitalocean/doctl/releases/latest/download/doctl-linux-amd64.tar.gz | tar xz - sudo mv doctl /usr/local/bin + run: sudo snap install doctl - name: Auth with DOCR run: | From c5eae2f6240d75eb7b6dd69c899669c49dde6607 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 10:35:06 -0500 Subject: [PATCH 06/14] Installed doctl system-wide and removed install line --- .gitea/workflows/deploy.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 9349d3b..516012b 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -12,9 +12,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install doctl - run: sudo snap install doctl - - name: Auth with DOCR run: | doctl auth init -t "${{ secrets.DO_TOKEN }}" From 9eb4674a3da1a8af7fbbcae8b60ae918b26d47a9 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 10:45:27 -0500 Subject: [PATCH 07/14] Remove doctl commands entirely, relying on system-installed and configured doctl command --- .gitea/workflows/deploy.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 516012b..00755a4 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -12,11 +12,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Auth with DOCR - run: | - doctl auth init -t "${{ secrets.DO_TOKEN }}" - doctl registry login - - name: Build image run: | docker build --platform linux/amd64 \ From 7c7b17911091c23cbaaddf2f47dcaa4c2817601b Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 10:53:05 -0500 Subject: [PATCH 08/14] Testing a different method of authenticating with DOCR --- .gitea/workflows/deploy.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 00755a4..f033828 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -19,6 +19,13 @@ jobs: -t registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest \ . + - name: Login to DigitalOcean Registry + uses: docker/login-action@v3 + with: + registry: registry.digitalocean.com + username: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} + password: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} + - name: Push image run: | docker push registry.digitalocean.com/radiantwave-portal-container-registry/portal:${{ gitea.sha }} From 849d806c038956ada2e580cad23e676d373884fd Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 11:19:55 -0500 Subject: [PATCH 09/14] synchronize database migrations --- .../20260325223339_remove_sub_constraint/migration.sql | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 prisma/migrations/20260325223339_remove_sub_constraint/migration.sql diff --git a/prisma/migrations/20260325223339_remove_sub_constraint/migration.sql b/prisma/migrations/20260325223339_remove_sub_constraint/migration.sql new file mode 100644 index 0000000..9ff2a51 --- /dev/null +++ b/prisma/migrations/20260325223339_remove_sub_constraint/migration.sql @@ -0,0 +1,3 @@ +-- Already applied directly to production database on 2026-03-25. +-- Drift migration: removed a constraint on Proclamation.subscriptionLevelId +-- Effect is reflected in the current schema. From bcd216bd562d66b28c6fb9e21f5ada2279bd96d0 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 11:24:02 -0500 Subject: [PATCH 10/14] Add startup script to automate database migrations --- Dockerfile | 7 ++++++- docker-entrypoint.sh | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100755 docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 94784d5..88a3922 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,11 @@ ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/digital-ocean.crt COPY --from=build /app/build ./build COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/package.json ./package.json +COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=build /app/src/lib/generated/prisma ./src/lib/generated/prisma +COPY --from=build /app/prisma ./prisma +COPY --from=build /app/prisma.config.ts ./prisma.config.ts +COPY docker-entrypoint.sh ./ EXPOSE 3000 -CMD ["node", "build/index.js"] \ No newline at end of file +CMD ["./docker-entrypoint.sh"] \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..d48fb8b --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "Running database migrations..." +npx prisma migrate deploy + +echo "Starting application..." +exec node build/index.js From fd630ff714adea989906d88e706b5ced9f768af1 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 11:51:14 -0500 Subject: [PATCH 11/14] Resolve build errors --- Dockerfile | 6 +++--- src/lib/server/email-verification.ts | 4 ++-- src/lib/server/resend.ts | 8 ++++---- src/routes/api/v1/automations/create/account/+server.ts | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 88a3922..18b8898 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,9 @@ FROM node:24-slim AS build WORKDIR /app -# Placeholder values ONLY so `vite build` doesn't crash on $env/static/* -# imports during the build step. These are NOT the real runtime values — -# App Platform injects the real ones as env vars when the container runs. +# Placeholder values for build-time $env/static/* and better-auth's module-scope +# secret validation. These are NOT the real runtime values — App Platform +# injects the real ones as env vars when the container runs. ARG DATABASE_URL=postgres://placeholder:placeholder@localhost:5432/placeholder ARG ORIGIN=http://localhost:3000 ARG BETTER_AUTH_URL=http://localhost:3000 diff --git a/src/lib/server/email-verification.ts b/src/lib/server/email-verification.ts index d0122a1..b5dfdb9 100644 --- a/src/lib/server/email-verification.ts +++ b/src/lib/server/email-verification.ts @@ -1,8 +1,8 @@ -import { BETTER_AUTH_URL } from '$env/static/private'; +import { env } from '$env/dynamic/private'; import { sendMail } from './resend'; export async function sendVerificationEmail(options: { email: string; token: string }) { - const verificationUrl = `${BETTER_AUTH_URL}/api/auth/verify-email?token=${options.token}&callbackURL=/login?verified=true`; + const verificationUrl = `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${options.token}&callbackURL=/login?verified=true`; await sendMail({ to: options.email, diff --git a/src/lib/server/resend.ts b/src/lib/server/resend.ts index dc8d7f4..3af07b7 100644 --- a/src/lib/server/resend.ts +++ b/src/lib/server/resend.ts @@ -9,9 +9,9 @@ * */ import { Resend } from 'resend'; -import { RESEND_API_KEY, EMAIL_FROM, RESEND_TEMPLATE_ID } from '$env/static/private'; +import { env } from '$env/dynamic/private'; -export const resend = new Resend(RESEND_API_KEY); +export const resend = new Resend(env.RESEND_API_KEY); /** * Send a transactional email using a Resend-hosted template. @@ -39,11 +39,11 @@ export async function sendMail(options: { const { to, subject, templateId, data } = options; const result = await resend.emails.send({ - from: EMAIL_FROM, + from: env.EMAIL_FROM, to: [to], subject: subject ?? 'Template Email', template: { - id: templateId ?? RESEND_TEMPLATE_ID, + id: templateId ?? env.RESEND_TEMPLATE_ID, variables: data ?? {} } }); diff --git a/src/routes/api/v1/automations/create/account/+server.ts b/src/routes/api/v1/automations/create/account/+server.ts index f62e994..dc11696 100644 --- a/src/routes/api/v1/automations/create/account/+server.ts +++ b/src/routes/api/v1/automations/create/account/+server.ts @@ -3,7 +3,7 @@ import { json, error } from '@sveltejs/kit'; import { auth } from '$lib/auth'; import { prisma } from '$lib/db'; import { sendMail } from '$lib/server/resend'; -import { BETTER_AUTH_URL } from '$env/static/private'; +import { env } from '$env/dynamic/private'; function generateLicenseKey(): string { const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase(); @@ -53,8 +53,8 @@ export const POST: RequestHandler = async ({ request }) => { }); const verifyUrl = verification - ? `${BETTER_AUTH_URL}/api/auth/verify-email?token=${verification.value}&callbackURL=/login?verified=true` - : `${BETTER_AUTH_URL}/login`; + ? `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${verification.value}&callbackURL=/login?verified=true` + : `${env.BETTER_AUTH_URL}/login`; await sendMail({ to: email, @@ -64,7 +64,7 @@ export const POST: RequestHandler = async ({ request }) => { firstName: name ?? email.split('@')[0], tempPassword, verifyUrl, - loginUrl: `${BETTER_AUTH_URL}/login` + loginUrl: `${env.BETTER_AUTH_URL}/login` } }); } catch (err) { From c52474d69bce51b52669678b23093821169986fc Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 12:42:42 -0500 Subject: [PATCH 12/14] Update environment variables in resend module --- src/lib/server/resend.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/lib/server/resend.ts b/src/lib/server/resend.ts index 3af07b7..790fb12 100644 --- a/src/lib/server/resend.ts +++ b/src/lib/server/resend.ts @@ -11,7 +11,14 @@ import { Resend } from 'resend'; import { env } from '$env/dynamic/private'; -export const resend = new Resend(env.RESEND_API_KEY); +let _resend: Resend; + +function getResend(): Resend { + if (!_resend) { + _resend = new Resend(env.RESEND_API_KEY); + } + return _resend; +} /** * Send a transactional email using a Resend-hosted template. @@ -38,7 +45,7 @@ export async function sendMail(options: { }) { const { to, subject, templateId, data } = options; - const result = await resend.emails.send({ + const result = await getResend().emails.send({ from: env.EMAIL_FROM, to: [to], subject: subject ?? 'Template Email', From dd8c194de2125c396ce2174d1907df28e7a6af11 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 13:28:40 -0500 Subject: [PATCH 13/14] Remove broken COPY command --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 18b8898..d647f67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,7 +48,6 @@ ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/digital-ocean.crt COPY --from=build /app/build ./build COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/package.json ./package.json -COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma COPY --from=build /app/src/lib/generated/prisma ./src/lib/generated/prisma COPY --from=build /app/prisma ./prisma COPY --from=build /app/prisma.config.ts ./prisma.config.ts From b746d894480a590c37320bf6c70524064a1bee92 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 17 Jul 2026 14:52:09 -0500 Subject: [PATCH 14/14] Fix non-serializable accessFilter in admin load, add error boundary and tests --- src/lib/svelte-admin/index.ts | 3 +- src/lib/svelte-admin/registry.ts | 21 ++++ src/routes/(protected)/admin/+error.svelte | 25 ++++ src/routes/(protected)/admin/+page.server.ts | 7 +- .../(protected)/admin/page.server.spec.ts | 107 ++++++++++++++++++ 5 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/routes/(protected)/admin/+error.svelte create mode 100644 src/routes/(protected)/admin/page.server.spec.ts diff --git a/src/lib/svelte-admin/index.ts b/src/lib/svelte-admin/index.ts index f20f9bd..b3cce53 100644 --- a/src/lib/svelte-admin/index.ts +++ b/src/lib/svelte-admin/index.ts @@ -3,4 +3,5 @@ export { ROLES, hasRole, canAccessAdmin } from './roles'; export { fieldNameToLabel, resolveFieldValue } from './introspect'; export { getDelegate, parseId } from './prisma-delegate'; export { extractFormData } from './form-data'; -export { getModelConfig, getAllModelConfigs } from './registry'; +export { getModelConfig, getAllModelConfigs, getSerializableModelConfigs } from './registry'; +export type { SerializableModelConfig } from './registry'; diff --git a/src/lib/svelte-admin/registry.ts b/src/lib/svelte-admin/registry.ts index b4b895a..dfe200f 100644 --- a/src/lib/svelte-admin/registry.ts +++ b/src/lib/svelte-admin/registry.ts @@ -267,3 +267,24 @@ export function getModelConfig(slug: string): AdminModelConfig | undefined { export function getAllModelConfigs(): AdminModelConfig[] { return models; } + +export type SerializableModelConfig = { + slug: string; + displayName: string; + listFields: string[]; + accessFilterResult: Record | undefined; +}; + +export function getSerializableModelConfigs( + viewerRoleId: number, + excludeSlugs?: string[] +): SerializableModelConfig[] { + return models + .filter((m) => !excludeSlugs?.includes(m.slug)) + .map((m) => ({ + slug: m.slug, + displayName: m.displayName, + listFields: m.listFields, + accessFilterResult: m.accessFilter?.(viewerRoleId) + })); +} diff --git a/src/routes/(protected)/admin/+error.svelte b/src/routes/(protected)/admin/+error.svelte new file mode 100644 index 0000000..52c7b81 --- /dev/null +++ b/src/routes/(protected)/admin/+error.svelte @@ -0,0 +1,25 @@ + + + +
+

+ Admin Error ({$page.status}) +

+

+ {#if $page.error?.message} + {$page.error.message} + {:else} + An unexpected error occurred while loading this admin page. + {/if} +

+ + Back to Admin + +
+
diff --git a/src/routes/(protected)/admin/+page.server.ts b/src/routes/(protected)/admin/+page.server.ts index 4c57c8e..20a8a46 100644 --- a/src/routes/(protected)/admin/+page.server.ts +++ b/src/routes/(protected)/admin/+page.server.ts @@ -1,7 +1,8 @@ import type { PageServerLoad } from './$types'; -import { getAllModelConfigs } from '$lib/svelte-admin/registry'; +import { getSerializableModelConfigs } from '$lib/svelte-admin/registry'; -export const load: PageServerLoad = async () => { - const models = getAllModelConfigs().filter((m) => m.slug !== 'license-keys'); +export const load: PageServerLoad = async ({ locals }) => { + const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4; + const models = getSerializableModelConfigs(viewerRoleId, ['license-keys']); return { models }; }; diff --git a/src/routes/(protected)/admin/page.server.spec.ts b/src/routes/(protected)/admin/page.server.spec.ts new file mode 100644 index 0000000..08e1883 --- /dev/null +++ b/src/routes/(protected)/admin/page.server.spec.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from 'vitest'; +import { getSerializableModelConfigs } from '$lib/svelte-admin/registry'; +import { createLocals, mockUser } from '$lib/test-utils'; + +vi.mock('$lib/db', () => ({})); + +import { load } from './+page.server'; + +function localsWithRole(roleId: number) { + return createLocals(mockUser({ roleId }) as any); +} + +describe('getSerializableModelConfigs', () => { + it('returns only serializable fields (no functions)', () => { + const models = getSerializableModelConfigs(1); + const serialized = JSON.stringify(models); + expect(serialized).toBeDefined(); + + for (const model of models) { + expect(typeof model.slug).toBe('string'); + expect(typeof model.displayName).toBe('string'); + expect(Array.isArray(model.listFields)).toBe(true); + expect( + typeof model.accessFilterResult === 'object' || model.accessFilterResult === undefined + ).toBe(true); + } + }); + + it('evaluates accessFilter for SUPER (roleId 1) — returns undefined for users', () => { + const models = getSerializableModelConfigs(1); + const users = models.find((m) => m.slug === 'users')!; + expect(users.accessFilterResult).toBeUndefined(); + }); + + it('evaluates accessFilter for ADMIN (roleId 2) — restricts users to STAFF+', () => { + const models = getSerializableModelConfigs(2); + const users = models.find((m) => m.slug === 'users')!; + expect(users.accessFilterResult).toEqual({ roleId: { gte: 3 } }); + }); + + it('evaluates accessFilter for STAFF (roleId 3) — restricts users to USER-only', () => { + const models = getSerializableModelConfigs(3); + const users = models.find((m) => m.slug === 'users')!; + expect(users.accessFilterResult).toEqual({ roleId: { gt: 3 } }); + }); + + it('models without accessFilter have undefined accessFilterResult', () => { + const models = getSerializableModelConfigs(1); + const roles = models.find((m) => m.slug === 'user-roles')!; + expect(roles.accessFilterResult).toBeUndefined(); + }); + + it('excludes specified slugs', () => { + const models = getSerializableModelConfigs(1, ['license-keys', 'users']); + expect(models.find((m) => m.slug === 'license-keys')).toBeUndefined(); + expect(models.find((m) => m.slug === 'users')).toBeUndefined(); + expect(models.length).toBeGreaterThan(0); + }); +}); + +describe('admin index page load', () => { + it('returns models with evaluated accessFilter results', async () => { + const result = (await load({ locals: localsWithRole(1) } as any)) as any; + expect(result.models).toBeDefined(); + expect(Array.isArray(result.models)).toBe(true); + + const users = result.models.find((m: any) => m.slug === 'users'); + expect(users).toBeDefined(); + expect(users.accessFilterResult).toBeUndefined(); + }); + + it('returns different accessFilter results per role', async () => { + const superResult = (await load({ locals: localsWithRole(1) } as any)) as any; + const adminResult = (await load({ locals: localsWithRole(2) } as any)) as any; + const staffResult = (await load({ locals: localsWithRole(3) } as any)) as any; + + const superUsers = superResult.models.find((m: any) => m.slug === 'users'); + const adminUsers = adminResult.models.find((m: any) => m.slug === 'users'); + const staffUsers = staffResult.models.find((m: any) => m.slug === 'users'); + + expect(superUsers.accessFilterResult).toBeUndefined(); + expect(adminUsers.accessFilterResult).toEqual({ roleId: { gte: 3 } }); + expect(staffUsers.accessFilterResult).toEqual({ roleId: { gt: 3 } }); + }); + + it('excludes license-keys model', async () => { + const result = (await load({ locals: localsWithRole(1) } as any)) as any; + const licenseKeys = result.models.find((m: any) => m.slug === 'license-keys'); + expect(licenseKeys).toBeUndefined(); + }); + + it('returns fully JSON-serializable data (no functions)', async () => { + const result = (await load({ locals: localsWithRole(2) } as any)) as any; + const serialized = JSON.stringify(result); + const parsed = JSON.parse(serialized); + expect(parsed.models).toBeDefined(); + expect(parsed.models.length).toBeGreaterThan(0); + }); + + it('defaults to roleId 4 when user has no roleId', async () => { + const result = (await load({ + locals: createLocals(mockUser({ roleId: null }) as any) + } as any)) as any; + const users = result.models.find((m: any) => m.slug === 'users'); + expect(users.accessFilterResult).toEqual({ roleId: { gt: 3 } }); + }); +});