);
diff --git a/proof-of-work/app/auth/signup/SignupForm.tsx b/proof-of-work/app/auth/signup/SignupForm.tsx
new file mode 100644
index 0000000..048f58f
--- /dev/null
+++ b/proof-of-work/app/auth/signup/SignupForm.tsx
@@ -0,0 +1,126 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { signupAction } from './actions';
+
+export default function SignupForm() {
+ const router = useRouter();
+ const [email, setEmail] = useState('');
+ const [name, setName] = useState('');
+ const [password, setPassword] = useState('');
+ const [passwordConfirm, setPasswordConfirm] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ const result = await signupAction(email, password, passwordConfirm, name);
+ if (result.error) {
+ setError(result.error);
+ setLoading(false);
+ return;
+ }
+ if (result.success) {
+ router.push('/main/dashboard');
+ }
+ } catch (err) {
+ setError('An unexpected error occurred');
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/proof-of-work/app/auth/signup/actions.ts b/proof-of-work/app/auth/signup/actions.ts
new file mode 100644
index 0000000..27d6ff5
--- /dev/null
+++ b/proof-of-work/app/auth/signup/actions.ts
@@ -0,0 +1,83 @@
+'use server';
+
+import { cookies } from 'next/headers';
+import { hashPassword, createSession } from '@/lib/auth';
+import { prisma } from '@/lib/prisma';
+import { getInstanceSettings } from '@/lib/instanceSettings';
+import { ensureLibraryForUser } from '@/lib/library';
+
+const EMAIL_RE = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
+
+export async function signupAction(
+ email: string,
+ password: string,
+ passwordConfirm: string,
+ name?: string,
+) {
+ try {
+ const settings = await getInstanceSettings();
+ if (!settings.signupsOpen) {
+ return { error: 'New sign-ups are not enabled on this instance.' };
+ }
+
+ if (!EMAIL_RE.test(email)) {
+ return { error: 'Enter a valid email address.' };
+ }
+ if (password.length < 8) {
+ return { error: 'Password must be at least 8 characters.' };
+ }
+ if (password !== passwordConfirm) {
+ return { error: 'Passwords do not match.' };
+ }
+
+ const existing = await prisma.user.findUnique({ where: { email } });
+ if (existing) {
+ // Don't leak existence — generic message keeps probing harder.
+ return { error: 'Could not create account with that email.' };
+ }
+
+ const passwordHash = await hashPassword(password);
+
+ const user = await prisma.user.create({
+ data: {
+ email,
+ passwordHash,
+ name: name?.trim() || null,
+ isAdmin: false,
+ userPreferences: {
+ create: {
+ theme: 'system',
+ defaultWeightUnit: 'lbs',
+ defaultRestSeconds: 90,
+ enableClaudeAI: false,
+ },
+ },
+ },
+ });
+
+ // Seed the curated exercise library for the new user immediately so they
+ // see exercises on first load. The boot-time ensure step would do this
+ // on next restart anyway, but we don't want them to wait.
+ await ensureLibraryForUser(user.id);
+
+ const session = await createSession(user.id);
+ const cookieStore = await cookies();
+ cookieStore.set('sessionToken', session.token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 60 * 60 * 24 * 30,
+ path: '/',
+ });
+
+ return { success: true };
+ } catch (error) {
+ console.error('Signup error:', error);
+ return { error: 'An error occurred during sign-up.' };
+ }
+}
+
+export async function getSignupsOpen(): Promise {
+ const settings = await getInstanceSettings();
+ return settings.signupsOpen;
+}
diff --git a/proof-of-work/app/auth/signup/page.tsx b/proof-of-work/app/auth/signup/page.tsx
new file mode 100644
index 0000000..99c5bb7
--- /dev/null
+++ b/proof-of-work/app/auth/signup/page.tsx
@@ -0,0 +1,56 @@
+import { redirect } from 'next/navigation';
+import { getSignupsOpen } from './actions';
+import SignupForm from './SignupForm';
+import { getCurrentUser } from '@/lib/auth';
+
+/**
+ * Server component wrapper. Reads the InstanceSettings.signupsOpen flag
+ * before rendering so closed instances don't even paint a form. Already-
+ * authenticated users get bounced to the dashboard rather than seeing
+ * sign-up.
+ */
+export default async function SignupPage() {
+ const user = await getCurrentUser();
+ if (user) {
+ redirect('/main/dashboard');
+ }
+
+ const open = await getSignupsOpen();
+
+ return (
+
+
+
+
+
+ Proof of Work
+
+
+ {open ? 'Create an account' : 'Sign-ups are closed'}
+
+
+
+
+ {open ? (
+
+ ) : (
+
+
+ This Proof of Work instance isn't accepting new
+ sign-ups right now. Ask the admin to enable sign-ups
+ from Settings (or via the StartOS Action) and try again.
+
+ When enabled, anyone with the URL can create an account on this
+ instance. New users start with the curated exercise library and
+ no admin privileges.
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/proof-of-work/lib/instanceSettings.ts b/proof-of-work/lib/instanceSettings.ts
new file mode 100644
index 0000000..a90c6d3
--- /dev/null
+++ b/proof-of-work/lib/instanceSettings.ts
@@ -0,0 +1,23 @@
+import { prisma } from "./prisma";
+
+/**
+ * Read the singleton InstanceSettings row, defensively creating it if
+ * missing. The compat ALTERs in docker_entrypoint.sh insert this row at
+ * boot time, but we still upsert here so that fresh dev installs (or
+ * any boot path that didn't run the entrypoint) see something usable.
+ */
+export async function getInstanceSettings() {
+ return prisma.instanceSettings.upsert({
+ where: { id: 1 },
+ update: {},
+ create: { id: 1, signupsOpen: false },
+ });
+}
+
+export async function setSignupsOpen(open: boolean) {
+ return prisma.instanceSettings.upsert({
+ where: { id: 1 },
+ update: { signupsOpen: open },
+ create: { id: 1, signupsOpen: open },
+ });
+}
diff --git a/proof-of-work/lib/library.ts b/proof-of-work/lib/library.ts
new file mode 100644
index 0000000..781b625
--- /dev/null
+++ b/proof-of-work/lib/library.ts
@@ -0,0 +1,69 @@
+import * as fs from "fs";
+import * as path from "path";
+import { prisma } from "./prisma";
+
+/**
+ * In-process equivalent of prisma/ensureExerciseLibrary.cjs (which runs at
+ * container boot via docker_entrypoint.sh). Used when a new user signs up
+ * mid-runtime so they don't have to wait for the next boot to see the
+ * curated library.
+ *
+ * Reads the curated library from prisma/exercises.seed.json, then upserts
+ * each entry for the given user. Idempotent; never overwrites a user's
+ * own custom exercises (the unique constraint on (userId, name) prevents
+ * duplicates and `update: {}` on upsert keeps existing rows untouched).
+ */
+
+interface LibraryExercise {
+ name: string;
+ description: string | null;
+ type: string;
+ muscleGroups: string[];
+ inputFields: string[];
+ defaultWeightUnit: string | null;
+}
+
+let cached: LibraryExercise[] | null = null;
+
+function loadLibrary(): LibraryExercise[] {
+ if (cached) return cached;
+ const candidates = [
+ // standalone runtime (Next.js server.js / Node)
+ path.resolve(process.cwd(), "prisma/exercises.seed.json"),
+ // .next/standalone runtime
+ path.resolve(process.cwd(), "../prisma/exercises.seed.json"),
+ // dev: cwd is repo subdir
+ path.resolve(__dirname, "../prisma/exercises.seed.json"),
+ ];
+ for (const p of candidates) {
+ if (fs.existsSync(p)) {
+ cached = JSON.parse(fs.readFileSync(p, "utf8")) as LibraryExercise[];
+ return cached;
+ }
+ }
+ console.warn("[library] exercises.seed.json not found in any candidate path");
+ return [];
+}
+
+export async function ensureLibraryForUser(userId: string): Promise {
+ const library = loadLibrary();
+ let inserted = 0;
+ for (const ex of library) {
+ const result = await prisma.exercise.upsert({
+ where: { userId_name: { userId, name: ex.name } },
+ update: {},
+ create: {
+ userId,
+ name: ex.name,
+ description: ex.description,
+ muscleGroups: JSON.stringify(ex.muscleGroups),
+ type: ex.type,
+ inputFields: JSON.stringify(ex.inputFields),
+ defaultWeightUnit: ex.defaultWeightUnit,
+ isCustom: false,
+ },
+ });
+ if (result) inserted++;
+ }
+ return inserted;
+}
diff --git a/proof-of-work/prisma/schema.prisma b/proof-of-work/prisma/schema.prisma
index c5a3f53..6f4af32 100644
--- a/proof-of-work/prisma/schema.prisma
+++ b/proof-of-work/prisma/schema.prisma
@@ -15,6 +15,7 @@ model User {
email String @unique
passwordHash String
name String?
+ isAdmin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -266,6 +267,16 @@ model AISuggestion {
@@index([type])
}
+/// Singleton row keyed on id=1. Holds instance-wide settings that aren't
+/// tied to any single user (multi-user signup gate, feature flags, etc.).
+/// Use `getInstanceSettings()` from lib/instanceSettings.ts to read; it
+/// upserts the row defensively on first read so callers never see null.
+model InstanceSettings {
+ id Int @id @default(1)
+ signupsOpen Boolean @default(false)
+ updatedAt DateTime @updatedAt
+}
+
model UserPreferences {
id String @id @default(cuid())
userId String @unique
diff --git a/proof-of-work/prisma/seed.ts b/proof-of-work/prisma/seed.ts
index 47b2fa2..1a7c735 100644
--- a/proof-of-work/prisma/seed.ts
+++ b/proof-of-work/prisma/seed.ts
@@ -48,15 +48,24 @@ async function main() {
const user = await prisma.user.upsert({
where: { email: "admin@local" },
- update: {},
+ update: { isAdmin: true },
create: {
email: "admin@local",
passwordHash: hashedPassword,
name: "Admin User",
+ isAdmin: true,
},
});
- console.log("Created/verified user:", user.id);
+ console.log("Created/verified admin user:", user.id);
+
+ await prisma.instanceSettings.upsert({
+ where: { id: 1 },
+ update: {},
+ create: { id: 1, signupsOpen: false },
+ });
+
+ console.log("Created/verified InstanceSettings (signupsOpen: false)");
await prisma.userPreferences.upsert({
where: { userId: user.id },
diff --git a/start9/0.4/docker_entrypoint.sh b/start9/0.4/docker_entrypoint.sh
index 5064658..de55e4f 100755
--- a/start9/0.4/docker_entrypoint.sh
+++ b/start9/0.4/docker_entrypoint.sh
@@ -75,6 +75,35 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
log "adding missing column Workout.deletedAt"
sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;"
fi
+
+ # Multi-user support shipped in v1.0.0:1: User.isAdmin column +
+ # InstanceSettings singleton. New install: seed.ts creates both. Upgrade
+ # from a snapshot pulled off the legacy `workout-log` package: this block
+ # adds them in place, then promotes the oldest user to admin so the
+ # in-app admin Settings panel + change-credentials action keep working.
+ if ! sqlite3 "$DB_PATH" "PRAGMA table_info('User');" 2>/dev/null | grep -q "|isAdmin|"; then
+ log "adding missing column User.isAdmin (default 0)"
+ sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN isAdmin INTEGER NOT NULL DEFAULT 0;"
+ log "promoting oldest user to admin (one-shot, only if no admin exists)"
+ sqlite3 "$DB_PATH" \
+ "UPDATE User SET isAdmin = 1 \
+ WHERE id = (SELECT id FROM User ORDER BY createdAt ASC LIMIT 1) \
+ AND NOT EXISTS (SELECT 1 FROM User WHERE isAdmin = 1);"
+ fi
+
+ if ! sqlite3 "$DB_PATH" \
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
+ 2>/dev/null | grep -q InstanceSettings; then
+ log "creating InstanceSettings table + singleton row (signupsOpen=0)"
+ sqlite3 "$DB_PATH" \
+ "CREATE TABLE InstanceSettings ( \
+ id INTEGER PRIMARY KEY DEFAULT 1, \
+ signupsOpen INTEGER NOT NULL DEFAULT 0, \
+ updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP \
+ );"
+ sqlite3 "$DB_PATH" \
+ "INSERT OR IGNORE INTO InstanceSettings (id, signupsOpen) VALUES (1, 0);"
+ fi
fi
# -----------------------------------------------------------------------------
diff --git a/start9/0.4/startos/actions/changeAdminCredentials.ts b/start9/0.4/startos/actions/changeAdminCredentials.ts
index 7f6d13b..8f9cd90 100644
--- a/start9/0.4/startos/actions/changeAdminCredentials.ts
+++ b/start9/0.4/startos/actions/changeAdminCredentials.ts
@@ -26,17 +26,19 @@ import { sdk } from '../sdk'
* subcontainer. The plaintext password never lands in /proc, the SQL log,
* or anywhere persistent.
*
- * - The UPDATE is keyed on `id = (SELECT id FROM User ORDER BY createdAt ASC
- * LIMIT 1)` rather than `WHERE email = 'admin@local'` (the original 0.3.5
- * default). That makes the action safe to re-run after a previous rotation.
- * The app is single-user by design, so this targets the only User row.
+ * - The UPDATE is keyed on the oldest user with `isAdmin = 1`, i.e. the
+ * primary admin identity. Safe to re-run after a previous rotation, and
+ * correct under the multi-user model: non-admin users created via
+ * /auth/signup are not targeted (admins reset other users' passwords
+ * from the in-app user management UI).
*
* - We assert exactly 1 row was updated (`changes() == 1`). Anything else
* means the schema/data is in an unexpected state and we abort without
* reporting success, so the user is forced to investigate before assuming
- * credentials rotated successfully.
- *
- * Available from package version 0.1.0:20 onward.
+ * credentials rotated successfully. If you see "expected exactly 1 user
+ * row updated", check that at least one User has isAdmin=1 — the boot-
+ * time compat ALTERs auto-promote the oldest user, but a corrupt or
+ * externally-edited DB might not have a valid admin.
*/
const EMAIL_PATTERN = '^[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}$'
@@ -137,7 +139,7 @@ export const changeAdminCredentials = sdk.Action.withInput(
`SET email = ${sqlQuote(input.email)},`,
` passwordHash = ${sqlQuote(passwordHash)},`,
` updatedAt = (strftime('%s','now') * 1000)`,
- `WHERE id = (SELECT id FROM User ORDER BY createdAt ASC LIMIT 1);`,
+ `WHERE id = (SELECT id FROM User WHERE isAdmin = 1 ORDER BY createdAt ASC LIMIT 1);`,
'SELECT changes();',
'COMMIT;',
].join('\n')
diff --git a/start9/0.4/startos/actions/index.ts b/start9/0.4/startos/actions/index.ts
index 5396f01..9c27fa4 100644
--- a/start9/0.4/startos/actions/index.ts
+++ b/start9/0.4/startos/actions/index.ts
@@ -1,11 +1,17 @@
import { sdk } from '../sdk'
import { changeAdminCredentials } from './changeAdminCredentials'
+import { toggleSignups } from './toggleSignups'
/**
* Package actions registered with StartOS.
*
- * - change-admin-credentials (added v0.1.0:20): rotate the admin email +
- * password from the StartOS UI without dropping to a shell. See
- * ./changeAdminCredentials.ts for full design notes.
+ * - change-admin-credentials: rotate the admin email + password from the
+ * StartOS UI without dropping to a shell. See changeAdminCredentials.ts
+ * for the full design notes.
+ * - toggle-signups: open/close the multi-user sign-up gate. The same
+ * toggle is also available in-app at Settings -> Instance Settings
+ * (admin only). See toggleSignups.ts.
*/
-export const actions = sdk.Actions.of().addAction(changeAdminCredentials)
+export const actions = sdk.Actions.of()
+ .addAction(changeAdminCredentials)
+ .addAction(toggleSignups)
diff --git a/start9/0.4/startos/actions/toggleSignups.ts b/start9/0.4/startos/actions/toggleSignups.ts
new file mode 100644
index 0000000..ae486ed
--- /dev/null
+++ b/start9/0.4/startos/actions/toggleSignups.ts
@@ -0,0 +1,118 @@
+import { sdk } from '../sdk'
+
+/**
+ * toggle-signups — StartOS Package Action.
+ *
+ * Sets `InstanceSettings.signupsOpen` (the multi-user signup gate).
+ * When `true`, anyone with the URL can create an account from the app's
+ * /auth/signup page. New users start with no admin privileges and the
+ * full curated exercise library.
+ *
+ * The same toggle is also available in-app at Settings -> Instance
+ * Settings (admin only). Both write to the same singleton row, so
+ * either path works. This StartOS action is the safety hatch for
+ * operators who don't have a working admin login (or aren't logged in
+ * yet on first install).
+ *
+ * Design notes:
+ * - allowedStatuses: 'only-running'. The app must be running for the
+ * write to be visible without restart, and the subcontainer needs
+ * /data mounted writable. We don't require a stop because the
+ * UPDATE is a single-row write that can't conflict with the
+ * long-running Next.js server.
+ * - Single explicit boolean input. Avoids the "I clicked it but did it
+ * turn on or off?" ambiguity of toggle-style actions.
+ * - The action does NOT report the current state in `getInput`. It's a
+ * setter, not a viewer; the in-app Settings page is the dashboard.
+ */
+
+export const toggleSignups = sdk.Action.withInput(
+ 'toggle-signups',
+ async () => ({
+ name: 'Set new signups',
+ description:
+ 'Allow or disallow anyone with the URL to create a Proof of Work account on this instance. The same toggle exists at in-app Settings -> Instance Settings (admin only).',
+ warning:
+ 'When sign-ups are open, anyone who can reach the URL can create an account. Make sure the instance is on a network you trust (LAN, Tor, VPN) before enabling.',
+ visibility: 'enabled',
+ allowedStatuses: 'only-running',
+ group: null,
+ }),
+ sdk.InputSpec.of({
+ signupsOpen: sdk.Value.toggle({
+ name: 'Allow new signups',
+ description: 'On = anyone with the URL can register. Off = closed.',
+ default: false,
+ }),
+ }),
+ async () => null,
+ async ({ effects, input }) => {
+ const flag = input.signupsOpen ? 1 : 0
+
+ await sdk.SubContainer.withTemp(
+ effects,
+ { imageId: 'main' },
+ sdk.Mounts.of().mountVolume({
+ volumeId: 'main',
+ subpath: null,
+ mountpoint: '/data',
+ readonly: false,
+ }),
+ 'toggle-signups',
+ async (sc) => {
+ // Defensive: make sure the table exists. The boot-time compat ALTERs
+ // create it, but if this action runs before a first proper boot we
+ // want it to still succeed.
+ const sql = [
+ `CREATE TABLE IF NOT EXISTS InstanceSettings (`,
+ ` id INTEGER PRIMARY KEY DEFAULT 1,`,
+ ` signupsOpen INTEGER NOT NULL DEFAULT 0,`,
+ ` updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP`,
+ `);`,
+ `INSERT OR IGNORE INTO InstanceSettings (id, signupsOpen) VALUES (1, ${flag});`,
+ `UPDATE InstanceSettings SET signupsOpen = ${flag}, updatedAt = CURRENT_TIMESTAMP WHERE id = 1;`,
+ `SELECT signupsOpen FROM InstanceSettings WHERE id = 1;`,
+ ].join('\n')
+
+ const res = await sc.execFail(
+ ['sqlite3', '/data/app.db'],
+ { input: sql },
+ 30_000,
+ )
+ const observed = res.stdout
+ .toString()
+ .split('\n')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .pop()
+ if (observed !== String(flag)) {
+ throw new Error(
+ `Aborting: wrote signupsOpen=${flag} but read back ${observed}. /data/app.db may be corrupt.`,
+ )
+ }
+ },
+ )
+
+ return {
+ version: '1',
+ title: input.signupsOpen ? 'Sign-ups enabled' : 'Sign-ups disabled',
+ message: input.signupsOpen
+ ? 'New visitors can now create accounts at /auth/signup.'
+ : 'New sign-ups are now closed. Existing users can still sign in.',
+ result: {
+ type: 'group',
+ value: [
+ {
+ type: 'single',
+ name: 'signupsOpen',
+ description: 'Current value of InstanceSettings.signupsOpen',
+ value: String(input.signupsOpen),
+ copyable: false,
+ qr: false,
+ masked: false,
+ },
+ ],
+ },
+ }
+ },
+)
diff --git a/start9/0.4/startos/versions/v1.0.0.1.ts b/start9/0.4/startos/versions/v1.0.0.1.ts
index b6038d7..4cd2ab4 100644
--- a/start9/0.4/startos/versions/v1.0.0.1.ts
+++ b/start9/0.4/startos/versions/v1.0.0.1.ts
@@ -25,7 +25,7 @@ export const v_1_0_0_1 = VersionInfo.of({
version: '1.0.0:1',
releaseNotes: {
en_US:
- 'Initial Proof of Work release. Replaces the legacy `workout-log` package with multi-user support and a curated exercise library shared across all users on the instance. Bakes a one-time seed of /data into the image and copies it into the new volume only on truly-fresh first boot, so an operator migrating from `workout-log` keeps every workout, exercise, and preference.',
+ 'Initial Proof of Work release. Replaces the legacy `workout-log` package with: (1) multi-user support — anyone with the URL can sign up when admin enables it, via Settings or the new "Set new signups" StartOS action; (2) a curated exercise library shared across all users — additive on every upgrade, so new exercises shipped by the maintainer reach existing installs without overwriting users\' own custom entries; (3) one-time seeded cutover from /data on the legacy `workout-log` host so every workout, exercise, and preference comes across; (4) the `change-admin-credentials` StartOS action targeting the primary admin (User.isAdmin = 1).',
},
migrations: {
up: async () => {},