diff --git a/config.default.json b/config.default.json index 9f1f9b9257..32bbf2782b 100644 --- a/config.default.json +++ b/config.default.json @@ -23,6 +23,7 @@ "default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997", "default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f", "storage_capacity": 104857600, + "disable_user_signup": false, "strict_email_verification_required": false, "gui_assets_root": "./src/gui", "puterjs_root": "./src/puter-js/dist", diff --git a/config.template.jsonc b/config.template.jsonc index a4cf33b2ff..23ed2515d3 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -75,6 +75,9 @@ "url_signature_secret": "change-me", "cookie_name": "puter_auth_token", "min_pass_length": 6, + // When true, anonymous users must log in instead of creating temp or + // permanent accounts. + "disable_user_signup": false, "allow_system_login": false, "strict_email_verification_required": false, "captcha": { diff --git a/doc/self-hosting.md b/doc/self-hosting.md index e677ce2fa3..af30004ffb 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -379,6 +379,15 @@ Built-in proof-of-work captcha — no external service needed. `difficulty` is one of `easy` / `medium` / `hard`. +### Disable new signups + +Force visitors to log in with an existing account instead of creating a +temporary or permanent one. + +```json +"disable_user_signup": true +``` + ### Block disposable email TLDs Only enforced when `env: "prod"`. diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 9ea325463b..412b02b723 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -586,6 +586,24 @@ describe('AuthController.handleSignup', () => { ); }); + it('rejects brand-new temp signups when registration is disabled', async () => { + const authConfig = server.controllers.auth.config as { + disable_user_signup?: boolean; + }; + const prev = authConfig.disable_user_signup; + authConfig.disable_user_signup = true; + try { + await expect( + controller.handleSignup(makeReq({ is_temp: true }), makeRes()), + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'signup_disabled', + }); + } finally { + authConfig.disable_user_signup = prev; + } + }); + it('emits puter.signup.success on successful signup', async () => { const baseline = heardSignupSuccess.length; const username = `s_${uniq()}`; @@ -607,6 +625,43 @@ describe('AuthController.handleSignup', () => { ), ).toBe(true); }); + + it('still allows claiming a pseudo-user row when registration is disabled', async () => { + const authConfig = server.controllers.auth.config as { + disable_user_signup?: boolean; + }; + const prev = authConfig.disable_user_signup; + authConfig.disable_user_signup = true; + try { + const targetEmail = `disabled_claim_${uniq()}@test.local`; + const placeholder = await server.stores.user.create({ + username: `placeholder_${uniq()}`, + uuid: uuidv4(), + password: null, + email: targetEmail, + clean_email: targetEmail, + email_confirmed: 0, + } as never); + + const res = makeRes(); + await controller.handleSignup( + makeReq({ + username: `claim_${uniq()}`, + email: targetEmail, + password: 'correct-horse-battery', + }), + res, + ); + + expect(isCompleteLoginResponse(res.body)).toBe(true); + const claimed = await server.stores.user.getById(placeholder.id, { + force: true, + }); + expect(claimed!.username).not.toBe(placeholder.username); + } finally { + authConfig.disable_user_signup = prev; + } + }); }); // -- Signup device signal (fingerprint) -- diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index f2d4a2caea..2851f0ee11 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -556,6 +556,11 @@ export class AuthController extends PuterController { pseudo_user = existing; } } + if (this.config.disable_user_signup && !pseudo_user) { + throw new HttpError(403, 'User registration is disabled.', { + legacyCode: 'signup_disabled', + }); + } // Extension-level validation gate. Abuse-prevention extensions // inspect the incoming signup and can: diff --git a/src/backend/controllers/homepage/HomepageController.test.ts b/src/backend/controllers/homepage/HomepageController.test.ts index d1bd139650..fa1c6f294d 100644 --- a/src/backend/controllers/homepage/HomepageController.test.ts +++ b/src/backend/controllers/homepage/HomepageController.test.ts @@ -155,14 +155,24 @@ const callRoute = async ( describe('HomepageController shell routes', () => { it('renders the live shell HTML on the root path', async () => { + const homepageConfig = server.controllers.homepage.config as { + disable_user_signup?: boolean; + }; + const prev = homepageConfig.disable_user_signup; + homepageConfig.disable_user_signup = true; const { res, captured } = makeRes(); - await callRoute('get', '/', makeReq({ path: '/' }), res); + try { + await callRoute('get', '/', makeReq({ path: '/' }), res); + } finally { + homepageConfig.disable_user_signup = prev; + } // PuterHomepageService.send writes the rendered HTML via res.send. expect(typeof captured.body).toBe('string'); const html = String(captured.body); expect(html).toMatch(//i); // The configured page title flows through the meta block. expect(html).toContain('Puter'); + expect(html).toContain('"disable_temp_users":true'); }); it('still serves the shell when an authenticated actor is present', async () => { diff --git a/src/backend/controllers/system/SystemController.js b/src/backend/controllers/system/SystemController.js index f56723d596..52532af8c5 100644 --- a/src/backend/controllers/system/SystemController.js +++ b/src/backend/controllers/system/SystemController.js @@ -137,6 +137,7 @@ export class SystemController extends PuterController { name: 'Puter', version: this.config.version ?? null, environment: this.config.env ?? 'prod', + disable_user_signup: Boolean(this.config.disable_user_signup), }); }); diff --git a/src/backend/controllers/system/SystemController.test.ts b/src/backend/controllers/system/SystemController.test.ts index f00f635b72..d15eaafd78 100644 --- a/src/backend/controllers/system/SystemController.test.ts +++ b/src/backend/controllers/system/SystemController.test.ts @@ -233,6 +233,7 @@ describe('SystemController GET /whoarewe', () => { expect(captured.body).toMatchObject({ name: 'Puter', environment: 'dev', + disable_user_signup: false, }); }); }); diff --git a/src/backend/services/auth/OIDCService.test.ts b/src/backend/services/auth/OIDCService.test.ts index 41569c175f..931821cbde 100644 --- a/src/backend/services/auth/OIDCService.test.ts +++ b/src/backend/services/auth/OIDCService.test.ts @@ -211,4 +211,25 @@ describe('OIDCService.createUserFromOIDC', () => { expect(result.success).toBe(false); expect(result.error).toMatch(/verify/i); }); + + it('refuses to create a fresh account when registration is disabled', async () => { + const oidcConfig = server.services.oidc.config as { + disable_user_signup?: boolean; + }; + const prev = oidcConfig.disable_user_signup; + oidcConfig.disable_user_signup = true; + try { + const result = await runWithContext({ req }, () => + oidc().createUserFromOIDC('microsoft', { + sub: 'disabled-sub', + email: 'disabled@example.com', + email_verified: true, + }), + ); + expect(result.success).toBe(false); + expect(result.error).toMatch(/disabled/i); + } finally { + oidcConfig.disable_user_signup = prev; + } + }); }); diff --git a/src/backend/services/auth/OIDCService.ts b/src/backend/services/auth/OIDCService.ts index c4fac56269..20aeffd148 100644 --- a/src/backend/services/auth/OIDCService.ts +++ b/src/backend/services/auth/OIDCService.ts @@ -443,6 +443,13 @@ export class OIDCService extends PuterService { }; } + if (this.config.disable_user_signup) { + return { + success: false, + error: 'User registration is disabled.', + }; + } + // Generate a unique username let username: string; let attempts = 0; diff --git a/src/backend/services/homepage/PuterHomepageService.ts b/src/backend/services/homepage/PuterHomepageService.ts index 8ee836f2ca..928d2dd297 100644 --- a/src/backend/services/homepage/PuterHomepageService.ts +++ b/src/backend/services/homepage/PuterHomepageService.ts @@ -171,6 +171,7 @@ export class PuterHomepageService extends PuterService { const guiParams: Record = { ...this.#guiParams, ...(this.config.gui_params ?? {}), + disable_temp_users: Boolean(this.config.disable_user_signup), domain: this.config.domain, env, api_base_url: this.config.api_base_url, diff --git a/src/backend/telemetry.ts b/src/backend/telemetry.ts index 3e7c8dd9e0..d8aed4257d 100644 --- a/src/backend/telemetry.ts +++ b/src/backend/telemetry.ts @@ -20,7 +20,7 @@ import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; -import { Resource } from '@opentelemetry/resources'; +import { resourceFromAttributes } from '@opentelemetry/resources'; import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; import { NodeSDK } from '@opentelemetry/sdk-node'; import { @@ -37,7 +37,7 @@ const endpoint = const sampleRatio = Number(process.env.OTEL_TRACE_SAMPLE_RATIO ?? 0.05); const sdk = new NodeSDK({ - resource: new Resource({ + resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'puter-backend', [ATTR_SERVICE_VERSION]: process.env.npm_package_version ?? '0.0.0', 'deployment.environment': process.env.NODE_ENV ?? 'development', diff --git a/src/backend/types.ts b/src/backend/types.ts index 29ab59d000..933b00b9af 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -568,6 +568,12 @@ interface IConfigOptional { min_pass_length: number; /** When true, allow the 'system' user to log in. */ allow_system_login: boolean; + /** + * When true, anonymous users cannot create new accounts or temporary + * sessions. Existing accounts can still log in, and pre-existing + * placeholder rows may still be claimed. + */ + disable_user_signup: boolean; /** Reject auth-gated routes unless the user has confirmed their email. */ strict_email_verification_required: boolean; /**