feat: add signup disable config and fix telemetry startup (#3319)

* feat: add signup disable config and fix telemetry startup

* fix: nits for config spread and 403 checks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-07-07 13:52:29 -07:00
1 parent a04d5fa5e0
commit 0801e1dc44
12 files changed
+219

No files matched your search

+1
View File
@@ -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",
+3
View File
@@ -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": {
+9
View File
@@ -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"`.
@@ -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,90 @@ 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;
}
});
it('does not reveal existing usernames or emails when registration is disabled', async () => {
const username = `taken_${uniq()}`;
const email = `${username}@test.local`;
await controller.handleSignup(
makeReq({ username, email, password: 'correct-horse-battery' }),
makeRes(),
);
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
};
const prev = authConfig.disable_user_signup;
authConfig.disable_user_signup = true;
try {
// Taken username → the generic 403, not the duplicate error.
await expect(
controller.handleSignup(
makeReq({
username,
email: `fresh_${uniq()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'signup_disabled',
});
// Taken (non-claimable) email → same generic 403.
await expect(
controller.handleSignup(
makeReq({
username: `fresh_${uniq()}`,
email,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'signup_disabled',
});
} finally {
authConfig.disable_user_signup = prev;
}
});
});
// -- Signup device signal (fingerprint) --
@@ -522,6 +522,31 @@ export class AuthController extends PuterController {
}
}
// Signup-disabled gate. Runs before the duplicate checks so a
// disabled endpoint doesn't reveal which usernames or emails
// exist. Claiming a pre-existing placeholder row is still
// allowed, so permanent signups look the email up first.
if (this.config.disable_user_signup) {
let claimable = false;
if (!is_temp) {
const existing =
(await this.stores.user.getByEmail(body.email)) ??
(await this.stores.user.getByCleanEmail(
cleanEmail(body.email),
));
claimable = Boolean(
existing &&
!existing.email_confirmed &&
existing.password === null,
);
}
if (!claimable) {
throw new HttpError(403, 'User registration is disabled.', {
legacyCode: 'signup_disabled',
});
}
}
// Duplicate username check
if (await this.stores.user.getByUsername(body.username)) {
throw new HttpError(
@@ -165,6 +165,43 @@ describe('HomepageController shell routes', () => {
expect(html).toContain('Puter');
});
it('exposes disable_temp_users to the GUI when signups are disabled', 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();
try {
await callRoute('get', '/', makeReq({ path: '/' }), res);
} finally {
homepageConfig.disable_user_signup = prev;
}
expect(String(captured.body)).toContain('"disable_temp_users":true');
});
it('keeps an operator-set gui_params.disable_temp_users when the flag is off', async () => {
const homepageConfig = server.controllers.homepage.config as {
disable_user_signup?: boolean;
gui_params?: Record<string, unknown>;
};
const prevFlag = homepageConfig.disable_user_signup;
const prevGuiParams = homepageConfig.gui_params;
homepageConfig.disable_user_signup = false;
homepageConfig.gui_params = {
...prevGuiParams,
disable_temp_users: true,
};
const { res, captured } = makeRes();
try {
await callRoute('get', '/', makeReq({ path: '/' }), res);
} finally {
homepageConfig.disable_user_signup = prevFlag;
homepageConfig.gui_params = prevGuiParams;
}
expect(String(captured.body)).toContain('"disable_temp_users":true');
});
it('still serves the shell when an authenticated actor is present', async () => {
const { actor } = await makeUser();
const { res, captured } = makeRes();
@@ -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),
});
});
@@ -233,6 +233,7 @@ describe('SystemController GET /whoarewe', () => {
expect(captured.body).toMatchObject({
name: 'Puter',
environment: 'dev',
disable_user_signup: false,
});
});
});
@@ -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
View File
@@ -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;
@@ -171,6 +171,12 @@ export class PuterHomepageService extends PuterService {
const guiParams: Record<string, unknown> = {
...this.#guiParams,
...(this.config.gui_params ?? {}),
// The config flag wins, but an operator-set
// `gui_params.disable_temp_users` must survive the override.
disable_temp_users: Boolean(
this.config.disable_user_signup ||
this.config.gui_params?.disable_temp_users,
),
domain: this.config.domain,
env,
api_base_url: this.config.api_base_url,
+6
View File
@@ -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;
/**