feat(signup): add Gleam signup service gated by shared invite code

A small Gleam HTTP server in signup/ exposes POST /signup that validates
a shared INVITE_CODE env var, then PUTs a user document to CouchDB's
_users database using the existing admin credentials. The frontend gets
back 201 + the username and is expected to call /_session itself to
obtain the AuthSession cookie (chosen over server-side cookie forwarding
because vaquant.at and couch.apoena.dev are different sites and the
cross-site cookie path is fragile under modern browser privacy modes).

Wired into docker-compose as a second service, reachable on
signup-couch.apoena.dev with its own Traefik labels and the same
hardcoded UUID network attachment as couchdb. Reuses COUCHDB_USER /
COUCHDB_PASSWORD for the admin call. INVITE_CODE must be set in Coolify
env vars.
This commit is contained in:
Julien Calixte
2026-06-01 22:16:29 +02:00
parent 67787bdf31
commit c3c75787f4
6 changed files with 324 additions and 0 deletions

3
signup/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
build/
.git/
*.beam

13
signup/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM ghcr.io/gleam-lang/gleam:v1.7.0-erlang-alpine AS builder
WORKDIR /build
COPY gleam.toml ./
RUN gleam deps download
COPY src ./src
RUN gleam export erlang-shipment
FROM erlang:27-alpine
RUN apk add --no-cache openssl-dev
WORKDIR /app
COPY --from=builder /build/build/erlang-shipment .
EXPOSE 8080
ENTRYPOINT ["/app/entrypoint.sh", "run"]

13
signup/gleam.toml Normal file
View File

@@ -0,0 +1,13 @@
name = "coolcouch_signup"
version = "0.1.0"
target = "erlang"
[dependencies]
gleam_stdlib = ">= 0.59.0 and < 2.0.0"
gleam_http = ">= 4.0.0 and < 5.0.0"
gleam_httpc = ">= 4.0.0 and < 5.0.0"
gleam_json = ">= 3.0.0 and < 4.0.0"
gleam_erlang = ">= 1.0.0 and < 2.0.0"
envoy = ">= 1.0.0 and < 2.0.0"
mist = ">= 4.0.0 and < 6.0.0"
wisp = ">= 1.6.0 and < 2.0.0"

View File

@@ -0,0 +1,235 @@
import envoy
import gleam/bit_array
import gleam/dynamic/decode
import gleam/erlang/process
import gleam/http.{Get, Options, Post}
import gleam/http/request
import gleam/http/response
import gleam/httpc
import gleam/int
import gleam/io
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import mist
import wisp.{type Request, type Response}
import wisp/wisp_mist
pub type Config {
Config(
couchdb_url: String,
admin_user: String,
admin_password: String,
invite_code: String,
allowed_origins: List(String),
port: Int,
)
}
pub type SignupBody {
SignupBody(username: String, password: String, invite_code: String)
}
pub fn main() {
wisp.configure_logger()
let config = case load_config() {
Ok(c) -> c
Error(msg) -> {
io.println_error("config error: " <> msg)
panic as "missing required configuration"
}
}
let secret = wisp.random_string(64)
let assert Ok(_) =
wisp_mist.handler(fn(req) { handle_request(req, config) }, secret)
|> mist.new
|> mist.port(config.port)
|> mist.bind("0.0.0.0")
|> mist.start
io.println("coolcouch-signup listening on " <> int.to_string(config.port))
process.sleep_forever()
}
fn load_config() -> Result(Config, String) {
use admin_password <- result.try(
envoy.get("COUCHDB_ADMIN_PASSWORD")
|> result.replace_error("missing COUCHDB_ADMIN_PASSWORD"),
)
use invite_code <- result.try(
envoy.get("INVITE_CODE")
|> result.replace_error("missing INVITE_CODE"),
)
let couchdb_url =
envoy.get("COUCHDB_URL") |> result.unwrap("http://couchdb:5984")
let admin_user = envoy.get("COUCHDB_ADMIN_USER") |> result.unwrap("admin")
let allowed_origins =
envoy.get("SIGNUP_ALLOWED_ORIGINS")
|> result.unwrap("")
|> string.split(",")
|> list.map(string.trim)
|> list.filter(fn(s) { s != "" })
let port =
envoy.get("PORT")
|> result.try(fn(p) {
int.parse(p) |> result.replace_error(Nil)
})
|> result.unwrap(8080)
Ok(Config(
couchdb_url: couchdb_url,
admin_user: admin_user,
admin_password: admin_password,
invite_code: invite_code,
allowed_origins: allowed_origins,
port: port,
))
}
fn handle_request(req: Request, config: Config) -> Response {
let allow_origin = case request.get_header(req, "origin") {
Ok(origin) ->
case list.contains(config.allowed_origins, origin) {
True -> Some(origin)
False -> None
}
Error(_) -> None
}
case req.method, wisp.path_segments(req) {
Options, _ -> preflight(allow_origin)
Get, ["health"] -> with_cors(wisp.ok(), allow_origin)
Post, ["signup"] -> with_cors(handle_signup(req, config), allow_origin)
_, _ -> with_cors(wisp.not_found(), allow_origin)
}
}
fn preflight(allow_origin: Option(String)) -> Response {
let base =
wisp.response(204)
|> wisp.set_header("access-control-allow-methods", "POST, OPTIONS")
|> wisp.set_header("access-control-allow-headers", "content-type")
|> wisp.set_header("access-control-max-age", "600")
|> wisp.set_header("vary", "Origin")
case allow_origin {
Some(origin) -> wisp.set_header(base, "access-control-allow-origin", origin)
None -> base
}
}
fn with_cors(resp: Response, allow_origin: Option(String)) -> Response {
case allow_origin {
Some(origin) ->
resp
|> wisp.set_header("access-control-allow-origin", origin)
|> wisp.set_header("vary", "Origin")
None -> resp
}
}
fn handle_signup(req: Request, config: Config) -> Response {
use body <- wisp.require_json(req)
let decoder = {
use username <- decode.field("username", decode.string)
use password <- decode.field("password", decode.string)
use invite_code <- decode.field("invite_code", decode.string)
decode.success(SignupBody(username:, password:, invite_code:))
}
case decode.run(body, decoder) {
Error(_) -> error_json(400, "invalid_payload")
Ok(SignupBody(username, password, invite_code)) ->
validate_and_create(config, username, password, invite_code)
}
}
fn validate_and_create(
config: Config,
username: String,
password: String,
invite_code: String,
) -> Response {
case invite_code == config.invite_code {
False -> error_json(401, "invalid_invite_code")
True ->
case valid_username(username), string.length(password) >= 8 {
False, _ -> error_json(400, "invalid_username")
_, False -> error_json(400, "password_too_short")
True, True -> create_user(config, username, password)
}
}
}
fn valid_username(name: String) -> Bool {
let len = string.length(name)
len >= 1 && len <= 64 && string.to_graphemes(name) |> list.all(allowed_char)
}
fn allowed_char(c: String) -> Bool {
case c {
"-" | "_" | "." -> True
_ ->
// letters and digits only (basic ASCII range)
case string.to_utf_codepoints(c) {
[cp] -> {
let n = string.utf_codepoint_to_int(cp)
{ n >= 0x30 && n <= 0x39 }
|| { n >= 0x41 && n <= 0x5A }
|| { n >= 0x61 && n <= 0x7A }
}
_ -> False
}
}
}
fn create_user(config: Config, username: String, password: String) -> Response {
let doc =
json.object([
#("_id", json.string("org.couchdb.user:" <> username)),
#("name", json.string(username)),
#("type", json.string("user")),
#("roles", json.array([], json.string)),
#("password", json.string(password)),
])
|> json.to_string
let url =
config.couchdb_url <> "/_users/org.couchdb.user:" <> username
let assert Ok(base_req) = request.to(url)
let req =
base_req
|> request.set_method(http.Put)
|> request.set_header("content-type", "application/json")
|> request.set_header("authorization", basic_auth(
config.admin_user,
config.admin_password,
))
|> request.set_body(doc)
case httpc.send(req) {
Error(_) -> error_json(502, "couchdb_unreachable")
Ok(resp) ->
case resp.status {
201 | 202 ->
json_response(
201,
json.object([
#("ok", json.bool(True)),
#("username", json.string(username)),
]),
)
409 -> error_json(409, "user_exists")
401 | 403 -> error_json(500, "admin_auth_failed")
_ -> error_json(502, "couchdb_error")
}
}
}
fn basic_auth(user: String, password: String) -> String {
let creds = user <> ":" <> password
"Basic " <> bit_array.base64_encode(<<creds:utf8>>, True)
}
fn error_json(status: Int, code: String) -> Response {
json_response(status, json.object([#("error", json.string(code))]))
}
fn json_response(status: Int, body: json.Json) -> Response {
wisp.json_response(json.to_string_tree(body), status)
}