diff --git a/backend/authentication/rpc.go b/backend/authentication/rpc.go
index 761cce6d..74f12a9c 100644
--- a/backend/authentication/rpc.go
+++ b/backend/authentication/rpc.go
@@ -18,6 +18,7 @@ import (
"time"
pb "github.com/cc-0000/indeq/common/api"
+ "github.com/cc-0000/indeq/common/util"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/argon2"
@@ -241,7 +242,7 @@ func (s *authServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb
}
// Generate a random OTP
- otp, err := generateOTP()
+ otp, err := util.GenerateCode("numeric")
if err != nil {
// if the OTP generation fails, return an error
return &pb.RegisterResponse{
@@ -337,7 +338,7 @@ func (s *authServer) ResendOTP(ctx context.Context, req *pb.ResendOTPRequest) (*
Error: "Something went wrong. Please try again later.",
}, nil
}
- newOTP, err := generateOTP()
+ newOTP, err := util.GenerateCode("numeric")
if err != nil {
// if the OTP generation fails, return an error
return &pb.ResendOTPResponse{
@@ -639,7 +640,7 @@ func (s *authServer) ForgotPassword(ctx context.Context, req *pb.ForgotPasswordR
req.Email = strings.ToLower(req.Email)
// generate a random OTP
- otp, err := generateOTP()
+ otp, err := util.GenerateCode("numeric")
if err != nil {
// if the OTP generation fails, return an error
return &pb.ForgotPasswordResponse{
diff --git a/backend/authentication/util.go b/backend/authentication/util.go
index c8e785bc..f62d4007 100644
--- a/backend/authentication/util.go
+++ b/backend/authentication/util.go
@@ -10,41 +10,9 @@ import (
"net/smtp"
"os"
"strings"
-
"golang.org/x/crypto/argon2"
)
-// func() (string, error)
-// - generates a secure 6-digit numeric OTP using crypto/rand
-// - avoids modulo bias by discarding bytes > 250
-// - returns: the OTP string and error (if random generation fails)
-func generateOTP() (string, error) {
- const digits = "0123456789"
- const length = 6
- otp := make([]byte, 0, length)
- // Use 250 as the maximum to avoid modulo bias
- max := byte(250)
- // buffer to read random bytes in batches
- buf := make([]byte, 16)
- for len(otp) < length {
- _, err := rand.Read(buf)
- if err != nil {
- return "", fmt.Errorf("failed to generate OTP: %w", err)
- }
- for _, b := range buf {
- if b > max {
- continue
- }
- otp = append(otp, digits[b%10])
- if len(otp) == length {
- break
- }
- }
- }
-
- return string(otp), nil
-}
-
// func(password string, argonParams *params) (string, error)
// - generates a secure password hash using the given Argon2 params
// - returns: the hashed password and error (if hashing fails)
diff --git a/backend/common/api/http.proto b/backend/common/api/http.proto
index 014e7391..08142e41 100644
--- a/backend/common/api/http.proto
+++ b/backend/common/api/http.proto
@@ -152,6 +152,16 @@ message HttpAddToWaitlistResponse {
string message = 2;
}
+message HttpValidateBetaCodeRequest {
+ string email = 1;
+ string beta_code = 2;
+}
+
+message HttpValidateBetaCodeResponse {
+ bool success = 1;
+ string message = 2;
+}
+
message HttpGetDesktopStatsRequest {
}
diff --git a/backend/common/api/waitlist.proto b/backend/common/api/waitlist.proto
index b9d7be26..37572262 100644
--- a/backend/common/api/waitlist.proto
+++ b/backend/common/api/waitlist.proto
@@ -6,6 +6,7 @@ package api;
service WaitlistService {
rpc AddToWaitlist(AddToWaitlistRequest) returns (AddToWaitlistResponse);
+ rpc ValidateBetaCode(ValidateBetaCodeRequest) returns (ValidateBetaCodeResponse);
}
message AddToWaitlistRequest {
@@ -15,4 +16,14 @@ message AddToWaitlistRequest {
message AddToWaitlistResponse {
bool success = 1;
string message = 2;
+}
+
+message ValidateBetaCodeRequest {
+ string email = 1;
+ string beta_code = 2;
+}
+
+message ValidateBetaCodeResponse {
+ bool success = 1;
+ string message = 2;
}
\ No newline at end of file
diff --git a/backend/common/util/code.go b/backend/common/util/code.go
new file mode 100644
index 00000000..98035798
--- /dev/null
+++ b/backend/common/util/code.go
@@ -0,0 +1,44 @@
+package util
+
+import (
+ "crypto/rand"
+ "fmt"
+)
+
+// GenerateCode generates a secure 6-character OTP.
+// Accepts mode: "numeric" or "alphanumeric".
+// Uses crypto/rand and avoids modulo bias.
+func GenerateCode(mode string) (string, error) {
+ var charset string
+ switch mode {
+ case "numeric":
+ charset = "0123456789"
+ case "alphanumeric":
+ charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ default:
+ return "", fmt.Errorf("invalid mode: %s", mode)
+ }
+
+ const otpLength = 6
+ const maxByte = 250 // to avoid modulo bias
+ otp := make([]byte, 0, otpLength)
+ buf := make([]byte, 16)
+
+ for len(otp) < otpLength {
+ _, err := rand.Read(buf)
+ if err != nil {
+ return "", fmt.Errorf("failed to generate OTP: %w", err)
+ }
+ for _, b := range buf {
+ if b > maxByte {
+ continue
+ }
+ otp = append(otp, charset[b%byte(len(charset))])
+ if len(otp) == otpLength {
+ break
+ }
+ }
+ }
+
+ return string(otp), nil
+}
diff --git a/backend/gateway/main.go b/backend/gateway/main.go
index f5a929aa..922db278 100644
--- a/backend/gateway/main.go
+++ b/backend/gateway/main.go
@@ -683,6 +683,39 @@ func handleAddToWaitlist(clients *ServiceClients) http.HandlerFunc {
}
}
+func handleValidateBetaCode(clients *ServiceClients) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var validateBetaCodeRequest pb.HttpValidateBetaCodeRequest
+ log.Println("Received validate beta code request")
+ if err := json.NewDecoder(r.Body).Decode(&validateBetaCodeRequest); err != nil {
+ log.Printf("Error: %v", err)
+ http.Error(w, "Bad Request", http.StatusBadRequest)
+ return
+ }
+
+ if validateBetaCodeRequest.Email == "" || validateBetaCodeRequest.BetaCode == "" {
+ http.Error(w, "Missing email or beta code", http.StatusBadRequest)
+ return
+ }
+
+ res, err := clients.waitlistClient.ValidateBetaCode(r.Context(), &pb.ValidateBetaCodeRequest{
+ BetaCode: validateBetaCodeRequest.BetaCode,
+ Email: validateBetaCodeRequest.Email,
+ })
+ if err != nil {
+ http.Error(w, "Failed to validate beta code", http.StatusInternalServerError)
+ return
+ }
+
+ httpResponse := &pb.HttpValidateBetaCodeResponse{
+ Success: res.Success,
+ Message: res.Message,
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(httpResponse)
+ }
+}
+
func handleGetDesktopStatsGenerator(clients *ServiceClients) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Set up context
@@ -1279,6 +1312,7 @@ func main() {
mux.HandleFunc("POST /api/ssooauth", handleSSOOAuthGenerator(serviceClients))
mux.HandleFunc("POST /api/ssologin", handleSSOLoginGenerator(serviceClients))
mux.HandleFunc("POST /api/waitlist", handleAddToWaitlist(serviceClients))
+ mux.HandleFunc("POST /api/validate-beta-code", handleValidateBetaCode(serviceClients))
mux.HandleFunc("GET /api/desktop_stats", authMiddleware(handleGetDesktopStatsGenerator(serviceClients), serviceClients))
mux.HandleFunc("POST /api/manualcrawl", authMiddleware(handleManualCrawlGenerator(serviceClients), serviceClients))
mux.HandleFunc("POST /api/verify-otp", handleVerifyOTPGenerator(serviceClients))
diff --git a/backend/waitlist/main.go b/backend/waitlist/main.go
index b3333d08..143b3a1a 100644
--- a/backend/waitlist/main.go
+++ b/backend/waitlist/main.go
@@ -7,9 +7,11 @@ import (
"net"
"net/mail"
"os"
+ "strings"
"time"
pb "github.com/cc-0000/indeq/common/api"
+ "github.com/cc-0000/indeq/common/util"
"github.com/cc-0000/indeq/common/config"
_ "github.com/lib/pq"
"google.golang.org/grpc"
@@ -23,6 +25,7 @@ type WaitlistServer struct {
func (s *WaitlistServer) AddToWaitlist(ctx context.Context, req *pb.AddToWaitlistRequest) (*pb.AddToWaitlistResponse, error) {
log.Println("Adding to waitlist:", req.Email)
+ req.Email = strings.ToLower(req.Email)
_, err := mail.ParseAddress(req.Email)
if err != nil {
return &pb.AddToWaitlistResponse{
@@ -31,10 +34,18 @@ func (s *WaitlistServer) AddToWaitlist(ctx context.Context, req *pb.AddToWaitlis
}, nil
}
+ betaCode, err := util.GenerateCode("alphanumeric")
+ if err != nil {
+ return &pb.AddToWaitlistResponse{
+ Success: false,
+ Message: "Something went wrong. Please try again later.",
+ }, nil
+ }
+
result, err := s.db.ExecContext(ctx, `
- INSERT INTO waitlist (email)
- VALUES ($1)
- ON CONFLICT (email) DO NOTHING`, req.Email)
+ INSERT INTO waitlist (email, beta_code)
+ VALUES ($1, $2)
+ ON CONFLICT (email) DO NOTHING`, req.Email, betaCode)
if err != nil {
log.Println("Database insert error:", err)
@@ -66,6 +77,40 @@ func (s *WaitlistServer) AddToWaitlist(ctx context.Context, req *pb.AddToWaitlis
}, nil
}
+func (s *WaitlistServer) ValidateBetaCode(ctx context.Context, req *pb.ValidateBetaCodeRequest) (*pb.ValidateBetaCodeResponse, error) {
+ log.Println("Validating beta code:", req.BetaCode)
+
+ if req.BetaCode == "" || req.Email == "" {
+ return &pb.ValidateBetaCodeResponse{
+ Success: false,
+ Message: "Invalid request",
+ }, nil
+ }
+
+ var betaCode string
+ err := s.db.QueryRowContext(ctx, `
+ SELECT beta_code FROM waitlist WHERE email = $1
+ `, req.Email).Scan(&betaCode)
+ if err != nil {
+ return &pb.ValidateBetaCodeResponse{
+ Success: false,
+ Message: "Could not validate beta code. Please try again later.",
+ }, nil
+ }
+
+ if betaCode != req.BetaCode {
+ return &pb.ValidateBetaCodeResponse{
+ Success: false,
+ Message: "Invalid beta code",
+ }, nil
+ }
+
+ return &pb.ValidateBetaCodeResponse{
+ Success: true,
+ Message: "Beta code validated successfully",
+ }, nil
+}
+
func main() {
log.Println("Starting the waitlist server...")
@@ -99,6 +144,7 @@ func main() {
CREATE TABLE IF NOT EXISTS waitlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
+ beta_code VARCHAR(6) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`)
diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts
index 8258e565..9404af5f 100644
--- a/frontend/src/hooks.server.ts
+++ b/frontend/src/hooks.server.ts
@@ -6,12 +6,14 @@ import { APP_ENV } from '$env/static/private';
export const handle: Handle = async ({ event, resolve }) => {
const jwt = event.cookies.get('jwt');
+ const betaPassed = event.cookies.get('betaPassed') === 'true';
const isAuthenticated = jwt && (await verifyToken(jwt));
const publicRoutes = [
'/',
'/login',
'/register',
+ '/beta-code',
'/enter-code',
'/forgot-password',
'/reset-password',
@@ -24,11 +26,14 @@ export const handle: Handle = async ({ event, resolve }) => {
];
const authRoutes = ['/login', '/register'];
+ const productionRoutes = ['/', '/terms', '/privacy', '/api/waitlist', '/sitemap.xml', '/login', '/beta-code'];
- const productionRoutes = ['/', '/terms', '/privacy', '/api/waitlist', '/sitemap.xml'];
-
- if (APP_ENV === 'PRODUCTION' && !productionRoutes.includes(event.url.pathname)) {
- return redirect(302, '/');
+ if (APP_ENV === 'PRODUCTION') {
+ if (!productionRoutes.includes(event.url.pathname)) {
+ if (!(event.url.pathname === '/register' && betaPassed)) {
+ return redirect(302, '/');
+ }
+ }
}
// Redirect authenticated users away from login and register pages
@@ -37,12 +42,11 @@ export const handle: Handle = async ({ event, resolve }) => {
}
if (!publicRoutes.includes(event.url.pathname)) {
- const isValid = jwt && (await verifyToken(jwt));
-
- if (!isValid) {
+ if (!isAuthenticated) {
return redirect(302, '/login');
}
}
return resolve(event);
};
+
diff --git a/frontend/src/lib/components/beta/BetaCodeCallout.svelte b/frontend/src/lib/components/beta/BetaCodeCallout.svelte
new file mode 100644
index 00000000..4acab7f5
--- /dev/null
+++ b/frontend/src/lib/components/beta/BetaCodeCallout.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/frontend/src/lib/components/ui/input-otp/input-otp.svelte b/frontend/src/lib/components/ui/input-otp/input-otp.svelte
index f7d48832..805a371c 100644
--- a/frontend/src/lib/components/ui/input-otp/input-otp.svelte
+++ b/frontend/src/lib/components/ui/input-otp/input-otp.svelte
@@ -13,6 +13,9 @@
/** Disable the whole control. */
export let disabled = false;
+ /** Mode of the input. */
+ export let mode: 'numeric' | 'alpha' = 'numeric';
+
const dispatch = createEventDispatcher<{ change: string; complete: string }>();
let inputs: HTMLInputElement[] = [];
@@ -27,10 +30,13 @@
function handleInput(e: Event, i: number) {
const el = e.target as HTMLInputElement;
let chars = el.value.replace(/\s+/g, '');
- if (!/^\d$/.test(chars)) {
- el.value = '';
- return;
- }
+
+ const isValidChar = mode === 'numeric' ? /^\d$/ : /^[a-zA-Z0-9]$/;
+ if (!isValidChar.test(chars)) {
+ el.value = '';
+ return;
+ }
+
const arr = value.split('');
for (let j = 0; j < chars.length && i + j < length; j++) {
arr[i + j] = chars[j];
@@ -70,7 +76,7 @@
function handlePaste(e: ClipboardEvent) {
const txt = (e.clipboardData?.getData('text') || '').replace(/\s+/g, '');
- if (/^\d+$/.test(txt)) {
+ if (mode === 'numeric' ? /^\d+$/.test(txt) : /^[a-zA-Z0-9]+$/.test(txt)) {
const digits = txt.slice(0, length).padEnd(length, '');
value = digits;
digits.split('').forEach((d, idx) => (inputs[idx].value = d));
@@ -101,8 +107,8 @@
class="w-12 h-12 text-center text-lg border rounded focus:outline-none
focus:ring focus:ring-primary disabled:opacity-50"
type="text"
- inputmode="numeric"
- pattern="\d*"
+ inputmode={mode === 'numeric' ? 'numeric' : 'text'}
+ pattern={mode === 'numeric' ? '\\d*' : '[a-zA-Z0-9]*'}
maxlength="1"
disabled={disabled}
value={value[i] ?? ''}
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index 732e7e95..db830a78 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -2,6 +2,8 @@
import { BoxIcon, MailIcon, LoaderIcon } from 'svelte-feather-icons';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
+ import { PUBLIC_GO_BACKEND_URL } from '$env/static/public';
+ import BetaCodeCallout from '$lib/components/beta/BetaCodeCallout.svelte';
let email = '';
let submitStatus: 'idle' | 'loading' | 'success' | 'error' = 'idle';
@@ -11,7 +13,7 @@
submitStatus = 'loading';
try {
- const response = await fetch('/api/waitlist', {
+ const response = await fetch(`${PUBLIC_GO_BACKEND_URL}/api/waitlist`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -158,6 +160,7 @@
+