From 8adacaa688b780cc0cea8b8e8445062345a1288d Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Fri, 9 Jan 2026 18:54:39 +0100 Subject: [PATCH] feat: Add single-user mode for personal pod servers Adds --single-user flag for personal/mobile deployments where only one user needs access to the pod. Features: - Creates pod automatically on startup if it doesn't exist - Disables registration endpoint (returns 403 with friendly message) - Configurable username via --single-user-name (default: 'me') - Root-level pod support: --single-user-name '' or '/' puts pod at / Usage: jss start --single-user --idp # Pod at /me/ jss start --single-user --single-user-name '' # Pod at / (root) jss start --single-user --single-user-name alice # Pod at /alice/ Closes #76 --- bin/jss.js | 7 +++- src/config.js | 6 ++++ src/idp/index.js | 53 ++++++++++++++++++--------- src/server.js | 93 ++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 139 insertions(+), 20 deletions(-) diff --git a/bin/jss.js b/bin/jss.js index d55f90d..69b7e2e 100755 --- a/bin/jss.js +++ b/bin/jss.js @@ -72,6 +72,8 @@ program .option('--ap-nostr-pubkey ', 'Nostr pubkey for identity linking') .option('--invite-only', 'Require invite code for registration') .option('--no-invite-only', 'Allow open registration') + .option('--single-user', 'Single-user mode (creates pod on startup, disables registration)') + .option('--single-user-name ', 'Username for single-user mode (default: me)') .option('--webid-tls', 'Enable WebID-TLS client certificate authentication') .option('--no-webid-tls', 'Disable WebID-TLS authentication') .option('-q, --quiet', 'Suppress log output') @@ -127,6 +129,8 @@ program apNostrPubkey: config.apNostrPubkey, inviteOnly: config.inviteOnly, webidTls: config.webidTls, + singleUser: config.singleUser, + singleUserName: config.singleUserName, }); await server.listen({ port: config.port, host: config.host }); @@ -149,7 +153,8 @@ program if (config.git) console.log(' Git: enabled (clone/push support)'); if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`); if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`); - if (config.inviteOnly) console.log(' Registration: invite-only'); + if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`); + else if (config.inviteOnly) console.log(' Registration: invite-only'); if (config.webidTls) console.log(' WebID-TLS: enabled (client certificate auth)'); console.log('\n Press Ctrl+C to stop\n'); } diff --git a/src/config.js b/src/config.js index 84c055d..d9b3f06 100644 --- a/src/config.js +++ b/src/config.js @@ -63,6 +63,10 @@ export const defaults = { // Invite-only registration inviteOnly: false, + // Single-user mode (personal pod server) + singleUser: false, + singleUserName: 'me', + // WebID-TLS client certificate authentication webidTls: false, @@ -109,6 +113,8 @@ const envMap = { JSS_AP_SUMMARY: 'apSummary', JSS_AP_NOSTR_PUBKEY: 'apNostrPubkey', JSS_INVITE_ONLY: 'inviteOnly', + JSS_SINGLE_USER: 'singleUser', + JSS_SINGLE_USER_NAME: 'singleUserName', JSS_WEBID_TLS: 'webidTls', JSS_DEFAULT_QUOTA: 'defaultQuota', }; diff --git a/src/idp/index.js b/src/idp/index.js index 07961b2..06ec8d7 100644 --- a/src/idp/index.js +++ b/src/idp/index.js @@ -27,7 +27,7 @@ import { addTrustedIssuer } from '../auth/solid-oidc.js'; * @param {string} options.issuer - The issuer URL */ export async function idpPlugin(fastify, options) { - const { issuer, inviteOnly = false } = options; + const { issuer, inviteOnly = false, singleUser = false } = options; if (!issuer) { throw new Error('IdP requires issuer URL'); @@ -272,25 +272,44 @@ export async function idpPlugin(fastify, options) { return handleAbort(request, reply, provider); }); - // Registration routes - fastify.get('/idp/register', async (request, reply) => { - return handleRegisterGet(request, reply, inviteOnly); - }); + // Registration routes (disabled in single-user mode) + if (singleUser) { + // Single-user mode: registration disabled + fastify.get('/idp/register', async (request, reply) => { + return reply.code(403).type('text/html').send(` + + Registration Disabled + +

Registration Disabled

+

This server is running in single-user mode. Registration is not available.

+

Login

+ + `); + }); + fastify.post('/idp/register', async (request, reply) => { + return reply.code(403).send({ error: 'Registration disabled in single-user mode' }); + }); + } else { + fastify.get('/idp/register', async (request, reply) => { + return handleRegisterGet(request, reply, inviteOnly); + }); - // Registration - rate limited to prevent spam accounts - fastify.post('/idp/register', { - config: { - rateLimit: { - max: 5, - timeWindow: '1 hour', - keyGenerator: (request) => request.ip + // Registration - rate limited to prevent spam accounts + fastify.post('/idp/register', { + config: { + rateLimit: { + max: 5, + timeWindow: '1 hour', + keyGenerator: (request) => request.ip + } } - } - }, async (request, reply) => { - return handleRegisterPost(request, reply, issuer, inviteOnly); - }); + }, async (request, reply) => { + return handleRegisterPost(request, reply, issuer, inviteOnly); + }); + } - fastify.log.info(`IdP initialized with issuer: ${issuer}`); + const modeInfo = singleUser ? ' (single-user mode, registration disabled)' : inviteOnly ? ' (invite-only)' : ''; + fastify.log.info(`IdP initialized with issuer: ${issuer}${modeInfo}`); } export default idpPlugin; diff --git a/src/server.js b/src/server.js index 125ab26..86ebb4a 100644 --- a/src/server.js +++ b/src/server.js @@ -4,7 +4,8 @@ import { readFile } from 'fs/promises'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { handleGet, handleHead, handlePut, handleDelete, handleOptions, handlePatch } from './handlers/resource.js'; -import { handlePost, handleCreatePod } from './handlers/container.js'; +import { handlePost, handleCreatePod, createPodStructure } from './handlers/container.js'; +import * as storage from './storage/filesystem.js'; import { getCorsHeaders } from './ldp/headers.js'; import { authorize, handleUnauthorized } from './auth/middleware.js'; import { notificationsPlugin } from './notifications/index.js'; @@ -71,6 +72,9 @@ export function createServer(options = {}) { const apNostrPubkey = options.apNostrPubkey ?? null; // Invite-only registration is OFF by default - open registration const inviteOnly = options.inviteOnly ?? false; + // Single-user mode - creates pod on startup, disables registration + const singleUser = options.singleUser ?? false; + const singleUserName = options.singleUserName ?? 'me'; // Default storage quota per pod (50MB default, 0 = unlimited) const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024; // WebID-TLS client certificate authentication is OFF by default @@ -165,7 +169,7 @@ export function createServer(options = {}) { // Register Identity Provider plugin if enabled if (idpEnabled) { - fastify.register(idpPlugin, { issuer: idpIssuer, inviteOnly }); + fastify.register(idpPlugin, { issuer: idpIssuer, inviteOnly, singleUser }); } // Register Nostr relay if enabled @@ -447,6 +451,91 @@ export function createServer(options = {}) { fastify.options('/', handleOptions); fastify.post('/', writeRateLimit, handlePost); + // Single-user mode: create pod on startup if it doesn't exist + if (singleUser) { + fastify.addHook('onReady', async () => { + // Determine base URL for pod URIs + const protocol = options.ssl ? 'https' : 'http'; + const host = options.host === '0.0.0.0' ? 'localhost' : (options.host || 'localhost'); + const port = options.port || 3000; + const baseUrl = idpIssuer?.replace(/\/$/, '') || `${protocol}://${host}:${port}`; + const issuer = idpIssuer || `${baseUrl}/`; + + // Root-level pod (empty or '/' name) vs named pod + const isRootPod = !singleUserName || singleUserName === '/'; + const podPath = isRootPod ? '/' : `/${singleUserName}/`; + const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`; + const webId = `${podUri}profile/card#me`; + const displayName = isRootPod ? 'me' : singleUserName; + + // Check if pod already exists (profile/card is the indicator) + const profileExists = await storage.exists(`${podPath}profile/card`); + + if (!profileExists) { + fastify.log.info(`Creating single-user pod at ${podUri}...`); + + if (isRootPod) { + // Root-level pod - create structure directly at / + await createRootPodStructure(webId, podUri, issuer, displayName); + } else { + // Named pod at /{name}/ + await createPodStructure(singleUserName, webId, podUri, issuer, defaultQuota); + } + fastify.log.info(`Single-user pod created at ${podUri}`); + } + }); + } + + /** + * Create root-level pod structure (for single-user mode with pod at /) + */ + async function createRootPodStructure(webId, podUri, issuer, displayName) { + const { generateProfile, generatePreferences, generateTypeIndex, serialize } = await import('./webid/profile.js'); + const { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } = await import('./wac/parser.js'); + + // Create directories at root + await storage.createContainer('/inbox/'); + await storage.createContainer('/public/'); + await storage.createContainer('/private/'); + await storage.createContainer('/Settings/'); + await storage.createContainer('/profile/'); + + // Generate profile + const profileHtml = generateProfile({ webId, name: displayName, podUri, issuer }); + await storage.write('/profile/card', profileHtml); + + // Preferences and type indexes + const prefs = generatePreferences({ webId, podUri }); + await storage.write('/Settings/Preferences.ttl', serialize(prefs)); + + const publicTypeIndex = generateTypeIndex(`${podUri}Settings/publicTypeIndex.ttl`); + await storage.write('/Settings/publicTypeIndex.ttl', serialize(publicTypeIndex)); + + const privateTypeIndex = generateTypeIndex(`${podUri}Settings/privateTypeIndex.ttl`); + await storage.write('/Settings/privateTypeIndex.ttl', serialize(privateTypeIndex)); + + // ACL files + const rootAcl = generateOwnerAcl(podUri, webId, true); + await storage.write('/.acl', serializeAcl(rootAcl)); + + const privateAcl = generatePrivateAcl(`${podUri}private/`, webId); + await storage.write('/private/.acl', serializeAcl(privateAcl)); + + const settingsAcl = generatePrivateAcl(`${podUri}Settings/`, webId); + await storage.write('/Settings/.acl', serializeAcl(settingsAcl)); + + const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId); + await storage.write('/inbox/.acl', serializeAcl(inboxAcl)); + + const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId); + await storage.write('/public/.acl', serializeAcl(publicAcl)); + + const profileAcl = generatePublicFolderAcl(`${podUri}profile/`, webId); + await storage.write('/profile/.acl', serializeAcl(profileAcl)); + + // Note: Quota not initialized for root-level pods (no user directory) + } + return fastify; }