Merge main into dev

This commit is contained in:
NeilClack
2026-07-18 15:51:43 -05:00
21 changed files with 366 additions and 31 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
.svelte-kit
build
.git
.env
.env.*
npm-debug.log*
.DS_Store
+1 -1
View File
@@ -1,7 +1,7 @@
DATABASE_URL=
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_ID=6
GOOGLE_CLIENT_SECRET=
RESEND_API_KEY=
EMAIL_FROM=
+32
View File
@@ -0,0 +1,32 @@
name: Build and Push to Digital Ocean Container Registry
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- 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: 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 }}
docker push registry.digitalocean.com/radiantwave-portal-container-registry/portal:latest
+57
View File
@@ -0,0 +1,57 @@
# --- Build stage ---
FROM node:24-slim AS build
WORKDIR /app
# 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
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
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 ["./docker-entrypoint.sh"]
+32
View File
@@ -1,7 +1,39 @@
# Rhema 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.
SvelteKit 2 + Svelte 5 + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth.
## 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.
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
## Admin
Admin CRUD pages at `/admin`. 9 models have per-page routes with modals for create/edit:
+26
View File
@@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIETTCCArWgAwIBAgIUSiKrBdf9KHXL2om54yA9sQTxeawwDQYJKoZIhvcNAQEM
BQAwQDE+MDwGA1UEAww1MTdmNmMxNDktZmQ1Yy00MTRmLTlkNjctYjIxYTc2MWE3
ZDlhIEdFTiAxIFByb2plY3QgQ0EwHhcNMjUwNDI5MTc0NDA5WhcNMzUwNDI3MTc0
NDA5WjBAMT4wPAYDVQQDDDUxN2Y2YzE0OS1mZDVjLTQxNGYtOWQ2Ny1iMjFhNzYx
YTdkOWEgR0VOIDEgUHJvamVjdCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCC
AYoCggGBANMf/9zhlInHTm5CrsnxHZQcqwy2sr4i4w2aWbpCa9j3rWcSyS7PAd+i
p2u1xTEoboJcbUghZYdmS92epVgejAHsHn4yx8LUsmdQvVF8imNlJfZA5mV09Voj
MpRwmXhQWCxyNqn/pmjMEKtpTm7TCr2GjEPDJC4gHSmPZF1EqJ3wWK+leoeZvRuE
omamd2oP60CxtHxt+8zcI6V80sC4QDze2qqRQ3rv1u2MkymUTAQT8kkb1pGmC/E+
wanXI1gI5nLqhMcoY55WujA/BOgGLWM1fNCIUWkAoQXUV6G0rM8aYDbTUvuTR0Fk
6LP96/bhlxuhrkTSbezO7kwGngiD0rKL4zKFjdwqBbbL1ZjAgfncmzb9zE6NW6Ad
Z8zcLgGCC0Z74GhXNsqNuxY2Mmqo0B/YUa1ahiFjNeXAk14mMXVGG7pbdu+YUUpU
I3duyxwefZsr3KHvz31HhX2jtcaVFrbOF/Svu23MqRhvj9cCUhiF34h7BqEqMFJP
AeIJ/O8xjwIDAQABoz8wPTAdBgNVHQ4EFgQUQyY3hBxq86mcYi8vRRIkCcDsmkMw
DwYDVR0TBAgwBgEB/wIBADALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggGB
AJqSrBQ8Qi6nIT1N0jo4WXQ5J9p9cyxYcaoRKkdOnvtnB9IXYwX6Ea4w7btZI3O5
eYamItTqNAC9ZZmwc2i16v1Nfpm50vGJjZZUMXYkxx1e9/kzOsUn4h6Q6H6+490Y
PAl7kn4Ngi7xU3bwxt0K80dU5p3a16r0A93y8GYW/WDuGRuGgrmRz6+psic4U8j4
nzHPFeJqtg9I00XYtzsFIIYrbAZT+jMuViVWCNAHJPyQYip8LkEzAfnlrszo9P9G
ieFN/wZbaPaVdju8wsSXN35yxy/eOsJ1p6gA4vzNdTtOmbMAlTQJ65QLu+HJrPpA
M5gjIf6QDSJyNpvWcjVROn6DiUnhsbe6EhRIPf6e8R0xwTe6y59XiU3sdsSDC0xp
3R2v7khln6IUimCsgs9lFvhOSZp4DmGuMs+CVCbqr8rtPTj5xWkeitkjlmiVSkg/
z2IItxzG4pGmlPc17TCgKzI6bkNoCZvJ92Wvq85q5LcQegJFQjASZKPFXNOm3SDq
wQ==
-----END CERTIFICATE-----
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Running database migrations..."
npx prisma migrate deploy
echo "Starting application..."
exec node build/index.js
+3
View File
@@ -53,6 +53,9 @@
"vite": "^7.2.6",
"vitest": "^4.0.15",
"vitest-browser-svelte": "^2.0.1"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@ark/schema": {
+3
View File
@@ -3,6 +3,9 @@
"private": true,
"version": "0.0.1",
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -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.
+4 -3
View File
@@ -5,9 +5,10 @@ import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import { sendMail } from './server/resend';
import { sendVerificationEmail } from './server/email-verification';
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'
}),
@@ -41,8 +42,8 @@ export const auth = betterAuth({
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 as string
}
},
rateLimit: {
+2 -11
View File
@@ -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);
+2 -2
View File
@@ -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,
+12 -5
View File
@@ -9,9 +9,16 @@
*
*/
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);
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,12 +45,12 @@ export async function sendMail(options: {
}) {
const { to, subject, templateId, data } = options;
const result = await resend.emails.send({
from: EMAIL_FROM,
const result = await getResend().emails.send({
from: env.EMAIL_FROM,
to: [to],
subject: subject ?? 'Template Email',
template: {
id: templateId ?? RESEND_TEMPLATE_ID,
id: templateId ?? env.RESEND_TEMPLATE_ID,
variables: data ?? {}
}
});
+2 -1
View File
@@ -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';
+21
View File
@@ -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<string, unknown> | 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)
}));
}
@@ -0,0 +1,25 @@
<script lang="ts">
import { page } from '$app/stores';
import Window from '$lib/components/containers/Window.svelte';
</script>
<Window>
<div class="flex flex-col items-center gap-4 p-6 text-center">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">
Admin Error ({$page.status})
</h1>
<p class="max-w-md text-sm text-gray-600 dark:text-gray-400">
{#if $page.error?.message}
{$page.error.message}
{:else}
An unexpected error occurred while loading this admin page.
{/if}
</p>
<a
href="/admin"
class="mt-2 border border-gray-200 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
>
Back to Admin
</a>
</div>
</Window>
+4 -3
View File
@@ -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 };
};
@@ -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 } });
});
});
@@ -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) {
+9
View File
@@ -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 },