Skip to content

Commit a245fe4

Browse files
feat: Add single-user mode for personal pod servers (JavaScriptSolidServer#77)
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 '' 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 JavaScriptSolidServer#76 Bumps version to 0.0.79
2 parents 005b669 + 8adacaa commit a245fe4

5 files changed

Lines changed: 140 additions & 21 deletions

File tree

bin/jss.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ program
7272
.option('--ap-nostr-pubkey <hex>', 'Nostr pubkey for identity linking')
7373
.option('--invite-only', 'Require invite code for registration')
7474
.option('--no-invite-only', 'Allow open registration')
75+
.option('--single-user', 'Single-user mode (creates pod on startup, disables registration)')
76+
.option('--single-user-name <name>', 'Username for single-user mode (default: me)')
7577
.option('--webid-tls', 'Enable WebID-TLS client certificate authentication')
7678
.option('--no-webid-tls', 'Disable WebID-TLS authentication')
7779
.option('-q, --quiet', 'Suppress log output')
@@ -127,6 +129,8 @@ program
127129
apNostrPubkey: config.apNostrPubkey,
128130
inviteOnly: config.inviteOnly,
129131
webidTls: config.webidTls,
132+
singleUser: config.singleUser,
133+
singleUserName: config.singleUserName,
130134
});
131135

132136
await server.listen({ port: config.port, host: config.host });
@@ -149,7 +153,8 @@ program
149153
if (config.git) console.log(' Git: enabled (clone/push support)');
150154
if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
151155
if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
152-
if (config.inviteOnly) console.log(' Registration: invite-only');
156+
if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`);
157+
else if (config.inviteOnly) console.log(' Registration: invite-only');
153158
if (config.webidTls) console.log(' WebID-TLS: enabled (client certificate auth)');
154159
console.log('\n Press Ctrl+C to stop\n');
155160
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "javascript-solid-server",
3-
"version": "0.0.78",
3+
"version": "0.0.79",
44
"description": "A minimal, fast Solid server",
55
"main": "src/index.js",
66
"type": "module",

src/config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export const defaults = {
6363
// Invite-only registration
6464
inviteOnly: false,
6565

66+
// Single-user mode (personal pod server)
67+
singleUser: false,
68+
singleUserName: 'me',
69+
6670
// WebID-TLS client certificate authentication
6771
webidTls: false,
6872

@@ -109,6 +113,8 @@ const envMap = {
109113
JSS_AP_SUMMARY: 'apSummary',
110114
JSS_AP_NOSTR_PUBKEY: 'apNostrPubkey',
111115
JSS_INVITE_ONLY: 'inviteOnly',
116+
JSS_SINGLE_USER: 'singleUser',
117+
JSS_SINGLE_USER_NAME: 'singleUserName',
112118
JSS_WEBID_TLS: 'webidTls',
113119
JSS_DEFAULT_QUOTA: 'defaultQuota',
114120
};

src/idp/index.js

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { addTrustedIssuer } from '../auth/solid-oidc.js';
3232
* @param {string} options.issuer - The issuer URL
3333
*/
3434
export async function idpPlugin(fastify, options) {
35-
const { issuer, inviteOnly = false } = options;
35+
const { issuer, inviteOnly = false, singleUser = false } = options;
3636

3737
if (!issuer) {
3838
throw new Error('IdP requires issuer URL');
@@ -277,23 +277,41 @@ export async function idpPlugin(fastify, options) {
277277
return handleAbort(request, reply, provider);
278278
});
279279

280-
// Registration routes
281-
fastify.get('/idp/register', async (request, reply) => {
282-
return handleRegisterGet(request, reply, inviteOnly);
283-
});
280+
// Registration routes (disabled in single-user mode)
281+
if (singleUser) {
282+
// Single-user mode: registration disabled
283+
fastify.get('/idp/register', async (request, reply) => {
284+
return reply.code(403).type('text/html').send(`
285+
<!DOCTYPE html>
286+
<html><head><title>Registration Disabled</title></head>
287+
<body style="font-family: system-ui; padding: 2rem; text-align: center;">
288+
<h1>Registration Disabled</h1>
289+
<p>This server is running in single-user mode. Registration is not available.</p>
290+
<p><a href="/idp/login">Login</a></p>
291+
</body></html>
292+
`);
293+
});
294+
fastify.post('/idp/register', async (request, reply) => {
295+
return reply.code(403).send({ error: 'Registration disabled in single-user mode' });
296+
});
297+
} else {
298+
fastify.get('/idp/register', async (request, reply) => {
299+
return handleRegisterGet(request, reply, inviteOnly);
300+
});
284301

285-
// Registration - rate limited to prevent spam accounts
286-
fastify.post('/idp/register', {
287-
config: {
288-
rateLimit: {
289-
max: 5,
290-
timeWindow: '1 hour',
291-
keyGenerator: (request) => request.ip
302+
// Registration - rate limited to prevent spam accounts
303+
fastify.post('/idp/register', {
304+
config: {
305+
rateLimit: {
306+
max: 5,
307+
timeWindow: '1 hour',
308+
keyGenerator: (request) => request.ip
309+
}
292310
}
293-
}
294-
}, async (request, reply) => {
295-
return handleRegisterPost(request, reply, issuer, inviteOnly);
296-
});
311+
}, async (request, reply) => {
312+
return handleRegisterPost(request, reply, issuer, inviteOnly);
313+
});
314+
}
297315

298316
// Passkey routes
299317
// Registration options - rate limited to prevent DoS
@@ -373,7 +391,8 @@ export async function idpPlugin(fastify, options) {
373391
return handleSchnorrComplete(request, reply, provider);
374392
});
375393

376-
fastify.log.info(`IdP initialized with issuer: ${issuer}`);
394+
const modeInfo = singleUser ? ' (single-user mode, registration disabled)' : inviteOnly ? ' (invite-only)' : '';
395+
fastify.log.info(`IdP initialized with issuer: ${issuer}${modeInfo}`);
377396
}
378397

379398
export default idpPlugin;

src/server.js

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { readFile } from 'fs/promises';
44
import { join, dirname } from 'path';
55
import { fileURLToPath } from 'url';
66
import { handleGet, handleHead, handlePut, handleDelete, handleOptions, handlePatch } from './handlers/resource.js';
7-
import { handlePost, handleCreatePod } from './handlers/container.js';
7+
import { handlePost, handleCreatePod, createPodStructure } from './handlers/container.js';
8+
import * as storage from './storage/filesystem.js';
89
import { getCorsHeaders } from './ldp/headers.js';
910
import { authorize, handleUnauthorized } from './auth/middleware.js';
1011
import { notificationsPlugin } from './notifications/index.js';
@@ -71,6 +72,9 @@ export function createServer(options = {}) {
7172
const apNostrPubkey = options.apNostrPubkey ?? null;
7273
// Invite-only registration is OFF by default - open registration
7374
const inviteOnly = options.inviteOnly ?? false;
75+
// Single-user mode - creates pod on startup, disables registration
76+
const singleUser = options.singleUser ?? false;
77+
const singleUserName = options.singleUserName ?? 'me';
7478
// Default storage quota per pod (50MB default, 0 = unlimited)
7579
const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
7680
// WebID-TLS client certificate authentication is OFF by default
@@ -165,7 +169,7 @@ export function createServer(options = {}) {
165169

166170
// Register Identity Provider plugin if enabled
167171
if (idpEnabled) {
168-
fastify.register(idpPlugin, { issuer: idpIssuer, inviteOnly });
172+
fastify.register(idpPlugin, { issuer: idpIssuer, inviteOnly, singleUser });
169173
}
170174

171175
// Register Nostr relay if enabled
@@ -447,6 +451,91 @@ export function createServer(options = {}) {
447451
fastify.options('/', handleOptions);
448452
fastify.post('/', writeRateLimit, handlePost);
449453

454+
// Single-user mode: create pod on startup if it doesn't exist
455+
if (singleUser) {
456+
fastify.addHook('onReady', async () => {
457+
// Determine base URL for pod URIs
458+
const protocol = options.ssl ? 'https' : 'http';
459+
const host = options.host === '0.0.0.0' ? 'localhost' : (options.host || 'localhost');
460+
const port = options.port || 3000;
461+
const baseUrl = idpIssuer?.replace(/\/$/, '') || `${protocol}://${host}:${port}`;
462+
const issuer = idpIssuer || `${baseUrl}/`;
463+
464+
// Root-level pod (empty or '/' name) vs named pod
465+
const isRootPod = !singleUserName || singleUserName === '/';
466+
const podPath = isRootPod ? '/' : `/${singleUserName}/`;
467+
const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`;
468+
const webId = `${podUri}profile/card#me`;
469+
const displayName = isRootPod ? 'me' : singleUserName;
470+
471+
// Check if pod already exists (profile/card is the indicator)
472+
const profileExists = await storage.exists(`${podPath}profile/card`);
473+
474+
if (!profileExists) {
475+
fastify.log.info(`Creating single-user pod at ${podUri}...`);
476+
477+
if (isRootPod) {
478+
// Root-level pod - create structure directly at /
479+
await createRootPodStructure(webId, podUri, issuer, displayName);
480+
} else {
481+
// Named pod at /{name}/
482+
await createPodStructure(singleUserName, webId, podUri, issuer, defaultQuota);
483+
}
484+
fastify.log.info(`Single-user pod created at ${podUri}`);
485+
}
486+
});
487+
}
488+
489+
/**
490+
* Create root-level pod structure (for single-user mode with pod at /)
491+
*/
492+
async function createRootPodStructure(webId, podUri, issuer, displayName) {
493+
const { generateProfile, generatePreferences, generateTypeIndex, serialize } = await import('./webid/profile.js');
494+
const { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } = await import('./wac/parser.js');
495+
496+
// Create directories at root
497+
await storage.createContainer('/inbox/');
498+
await storage.createContainer('/public/');
499+
await storage.createContainer('/private/');
500+
await storage.createContainer('/Settings/');
501+
await storage.createContainer('/profile/');
502+
503+
// Generate profile
504+
const profileHtml = generateProfile({ webId, name: displayName, podUri, issuer });
505+
await storage.write('/profile/card', profileHtml);
506+
507+
// Preferences and type indexes
508+
const prefs = generatePreferences({ webId, podUri });
509+
await storage.write('/Settings/Preferences.ttl', serialize(prefs));
510+
511+
const publicTypeIndex = generateTypeIndex(`${podUri}Settings/publicTypeIndex.ttl`);
512+
await storage.write('/Settings/publicTypeIndex.ttl', serialize(publicTypeIndex));
513+
514+
const privateTypeIndex = generateTypeIndex(`${podUri}Settings/privateTypeIndex.ttl`);
515+
await storage.write('/Settings/privateTypeIndex.ttl', serialize(privateTypeIndex));
516+
517+
// ACL files
518+
const rootAcl = generateOwnerAcl(podUri, webId, true);
519+
await storage.write('/.acl', serializeAcl(rootAcl));
520+
521+
const privateAcl = generatePrivateAcl(`${podUri}private/`, webId);
522+
await storage.write('/private/.acl', serializeAcl(privateAcl));
523+
524+
const settingsAcl = generatePrivateAcl(`${podUri}Settings/`, webId);
525+
await storage.write('/Settings/.acl', serializeAcl(settingsAcl));
526+
527+
const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId);
528+
await storage.write('/inbox/.acl', serializeAcl(inboxAcl));
529+
530+
const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId);
531+
await storage.write('/public/.acl', serializeAcl(publicAcl));
532+
533+
const profileAcl = generatePublicFolderAcl(`${podUri}profile/`, webId);
534+
await storage.write('/profile/.acl', serializeAcl(profileAcl));
535+
536+
// Note: Quota not initialized for root-level pods (no user directory)
537+
}
538+
450539
return fastify;
451540
}
452541

0 commit comments

Comments
 (0)