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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,18 @@ NEXT_PUBLIC_WS_URL=
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)
# REMOTE_API_URL=https://multica-api.copilothub.ai

# ==================== Self-hosting: Control Signups (fixes #930) ====================
# Set to "false" to completely disable new user signups (recommended for private instances)
ALLOW_SIGNUP=true
# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting.
# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle,
# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend).
NEXT_PUBLIC_ALLOW_SIGNUP=true

# Optional: Only allow emails from these domains (comma-separated)
ALLOWED_EMAIL_DOMAINS=

# Optional: Only allow these exact email addresses (comma-separated)
ALLOWED_EMAILS=
Comment thread
azaanaliraza marked this conversation as resolved.

9 changes: 6 additions & 3 deletions apps/web/features/landing/i18n/en.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";

export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";

export const en: LandingDict = {
header: {
github: "GitHub",
Expand Down Expand Up @@ -119,9 +121,10 @@ export const en: LandingDict = {
headlineFaded: "in the next hour.",
steps: [
{
title: "Sign up & create your workspace",
description:
"Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.",
title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Log in to your workspace",
description: ALLOW_SIGNUP
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
},
{
title: "Install the CLI & connect your machine",
Expand Down
9 changes: 6 additions & 3 deletions apps/web/features/landing/i18n/zh.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { githubUrl } from "../components/shared";
import type { LandingDict } from "./types";

export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";

export const zh: LandingDict = {
header: {
github: "GitHub",
Expand Down Expand Up @@ -119,9 +121,10 @@ export const zh: LandingDict = {
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
steps: [
{
title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a",
description:
"\u8f93\u5165\u90ae\u7bb1\uff0c\u9a8c\u8bc1\u7801\u786e\u8ba4\uff0c\u5373\u53ef\u8fdb\u5165\u3002\u5de5\u4f5c\u533a\u81ea\u52a8\u521b\u5efa\u2014\u2014\u65e0\u9700\u8bbe\u7f6e\u5411\u5bfc\uff0c\u65e0\u9700\u914d\u7f6e\u8868\u5355\u3002",
title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间",
description: ALLOW_SIGNUP
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
},
{
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
Expand Down
114 changes: 100 additions & 14 deletions server/internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handler

import (
"context"
"errors"
"crypto/rand"
"crypto/subtle"
"encoding/binary"
Expand All @@ -22,6 +23,18 @@ import (
db "github.com/multica-ai/multica/server/pkg/db/generated"
)

// SignupError represents signup restriction errors
type SignupError struct {
Message string
}

func (e SignupError) Error() string {
return e.Message
}

var ErrSignupProhibited = SignupError{Message: "user registration is disabled on this self-hosted instance"}
var ErrEmailNotAllowed = SignupError{Message: "email address or domain not allowed on this instance"}

type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Expand Down Expand Up @@ -78,23 +91,68 @@ func (h *Handler) issueJWT(user db.User) (string, error) {

func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User, error) {
user, err := h.Queries.GetUserByEmail(ctx, email)
if err != nil {
if !isNotFound(err) {
return db.User{}, err
}
name := email
if at := strings.Index(email, "@"); at > 0 {
name = email[:at]
if err == nil {
return user, nil
}
if !isNotFound(err) {
return db.User{}, err
}

// New user creation path. Check if signups are allowed.
if err := h.checkSignupAllowed(email); err != nil {
return db.User{}, err
}
Comment on lines +101 to +104
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signup gating (ALLOW_SIGNUP / ALLOWED_EMAIL_DOMAINS / ALLOWED_EMAILS) is new behavior but there are no handler tests asserting the expected 403 outcomes or the “existing users can still log in” path. Add tests covering: (1) ALLOW_SIGNUP=false blocks new user registration, (2) allowlist mismatch blocks new user registration, and (3) an existing user can still authenticate when signups are disabled.

Copilot uses AI. Check for mistakes.

name := email
if at := strings.Index(email, "@"); at > 0 {
name = email[:at]
}
return h.Queries.CreateUser(ctx, db.CreateUserParams{
Name: name,
Email: email,
})
}

func (h *Handler) checkSignupAllowed(email string) error {
if os.Getenv("ALLOW_SIGNUP") == "false" {
return ErrSignupProhibited
}

allowedDomainsStr := os.Getenv("ALLOWED_EMAIL_DOMAINS")
allowedEmailsStr := os.Getenv("ALLOWED_EMAILS")

Comment thread
azaanaliraza marked this conversation as resolved.
if allowedDomainsStr == "" && allowedEmailsStr == "" {
return nil
}

allowed := false
emailLower := strings.ToLower(email)

if allowedDomainsStr != "" {
for _, domain := range strings.Split(allowedDomainsStr, ",") {
domain = strings.TrimSpace(domain)
if domain != "" && strings.HasSuffix(emailLower, "@"+strings.ToLower(domain)) {
allowed = true
break
}
}
user, err = h.Queries.CreateUser(ctx, db.CreateUserParams{
Name: name,
Email: email,
})
if err != nil {
return db.User{}, err
}

if !allowed && allowedEmailsStr != "" {
for _, allowedEmail := range strings.Split(allowedEmailsStr, ",") {
allowedEmail = strings.TrimSpace(allowedEmail)
if allowedEmail != "" && strings.EqualFold(emailLower, allowedEmail) {
allowed = true
break
}
}
Comment thread
azaanaliraza marked this conversation as resolved.
}
Comment thread
azaanaliraza marked this conversation as resolved.
return user, nil

if !allowed {
return ErrEmailNotAllowed
}

return nil
}

func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
Expand All @@ -110,6 +168,24 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
return
}

// Short-circuit: If not already a user, check if signups are allowed for this email
_, err := h.Queries.GetUserByEmail(r.Context(), email)
if err != nil {
if !isNotFound(err) {
writeError(w, http.StatusInternalServerError, "failed to look up user")
return
}
if err := h.checkSignupAllowed(email); err != nil {
msg := "user registration is disabled"
var signupErr SignupError
if errors.As(err, &signupErr) {
msg = signupErr.Message
}
writeError(w, http.StatusForbidden, msg)
return
}
}

// Rate limit: max 1 code per 60 seconds per email
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
Expand Down Expand Up @@ -180,6 +256,11 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {

user, err := h.findOrCreateUser(r.Context(), email)
if err != nil {
var signupErr SignupError
if errors.As(err, &signupErr) {
writeError(w, http.StatusForbidden, signupErr.Error())
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
Expand Down Expand Up @@ -336,6 +417,11 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {

user, err := h.findOrCreateUser(r.Context(), email)
if err != nil {
var signupErr SignupError
if errors.As(err, &signupErr) {
writeError(w, http.StatusForbidden, signupErr.Error())
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
Expand Down