mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-08 21:02:33 +00:00
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:
12 files changed
+219
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user