Skip to content
Open
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
1 change: 1 addition & 0 deletions config.default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions config.template.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
9 changes: 9 additions & 0 deletions doc/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Expand Down
55 changes: 55 additions & 0 deletions src/backend/controllers/auth/AuthController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`;
Expand All @@ -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) --
Expand Down
5 changes: 5 additions & 0 deletions src/backend/controllers/auth/AuthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion src/backend/controllers/homepage/HomepageController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<!DOCTYPE html>/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 () => {
Expand Down
1 change: 1 addition & 0 deletions src/backend/controllers/system/SystemController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
});

Expand Down
1 change: 1 addition & 0 deletions src/backend/controllers/system/SystemController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ describe('SystemController GET /whoarewe', () => {
expect(captured.body).toMatchObject({
name: 'Puter',
environment: 'dev',
disable_user_signup: false,
});
});
});
Expand Down
21 changes: 21 additions & 0 deletions src/backend/services/auth/OIDCService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
});
7 changes: 7 additions & 0 deletions src/backend/services/auth/OIDCService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/backend/services/homepage/PuterHomepageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export class PuterHomepageService extends PuterService {
const guiParams: Record<string, unknown> = {
...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,
Expand Down
4 changes: 2 additions & 2 deletions src/backend/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions src/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
Loading