Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion bin/jss.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ program
.option('--ap-nostr-pubkey <hex>', '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 <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')
Expand Down Expand Up @@ -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 });
Expand All @@ -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');
}
Expand Down
6 changes: 6 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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',
};
Expand Down
53 changes: 36 additions & 17 deletions src/idp/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(`
<!DOCTYPE html>
<html><head><title>Registration Disabled</title></head>
<body style="font-family: system-ui; padding: 2rem; text-align: center;">
<h1>Registration Disabled</h1>
<p>This server is running in single-user mode. Registration is not available.</p>
<p><a href="/idp/login">Login</a></p>
</body></html>
`);
});
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;
93 changes: 91 additions & 2 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down