feat(admin): session probe, self-hosted signup and the relink cap

Adds GET /auth/me so the admin site's route guards can know who is signed
in, POST /auth/signup for self-hosted customers, and max_relinks on the
account payload so the UI never hardcodes a rule the backend enforces.

Signup follows sitesvc's proven shape: honeypot answered as success, a
generic 201 when the address already exists, and nothing usable until the
emailed link is opened.

Also fixes a lockout found while verifying it. When the verification email
failed, the account was rolled back but the customer_users row survived --
an orphan that can never be signed in to and that holds the unique index on
email, so every later signup with that address got a cheerful 201 and the
customer was locked out of their own address with no visible error.
CreateCustomerUser now undoes its own insert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 20:48:09 +01:00
co-authored by Claude Opus 5
parent 79afcc2e16
commit 4bb7400b8e
6 changed files with 153 additions and 17 deletions
@@ -98,7 +98,7 @@ Without `GET /auth/me` no route guard can know who is signed in, and without sig
- `POST /auth/signup` → `201 {"pending":true}`
- `GET /api/account` gains `"max_relinks": 3`
- [ ] **Step 1: Add the session probe**
- [x] **Step 1: Add the session probe**
Append to `admin/internal/api/customer.go`:
@@ -122,7 +122,7 @@ func getMe(c *gin.Context) {
}
```
- [ ] **Step 2: Export the session loader**
- [x] **Step 2: Export the session loader**
`load` in `admin/internal/auth/middleware.go` is unexported. Rename it to `Load` and update its two call sites in the same file:
@@ -134,7 +134,7 @@ func Load(c *gin.Context) *Session {
Both `RequireStaff` and `RequireCustomer` call `s := Load(c)`.
- [ ] **Step 3: Add signup**
- [x] **Step 3: Add signup**
Append to `admin/internal/auth/customer.go`:
@@ -209,7 +209,39 @@ func HandleSignup(c *gin.Context) {
Add `"time"` and `"github.com/google/uuid"` to that file's imports.
- [ ] **Step 4: Expose the relink cap**
- [x] **Step 3b: Make `CreateCustomerUser` undo its own insert**
Found while verifying Step 7: when the verification email fails, `HandleSignup`
rolls the account back but the `customer_users` row survives. That orphan can
never be signed in to *and* it holds the unique index on `email`, so the next
signup with that address hits the "already exists" branch and gets a cheerful
`201` forever — the customer is locked out of their own address with no error
anyone can see.
In `admin/internal/auth/customer.go`, replace the tail of `CreateCustomerUser`:
```go
if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
return err
}
if err := mail.SendVerification(u.Email, token); err != nil {
// Undo the insert. A row whose verification link was never delivered is
// worse than no row: it can never be signed in to, and it holds the
// unique index on email, so the customer cannot sign up again with the
// address they just used.
_, _ = db.Admin("customer_users").DeleteOne(ctx, bson.M{"user_id": u.UserID})
return err
}
return nil
}
```
Confirm by posting the same signup twice with SMTP unconfigured: both must
return `500`, and both collections must be empty afterwards. Before the fix the
second call returns `201`.
- [x] **Step 4: Expose the relink cap**
In `admin/internal/api/customer.go`, replace the final `c.JSON` of `getAccount`:
@@ -223,7 +255,7 @@ In `admin/internal/api/customer.go`, replace the final `c.JSON` of `getAccount`:
})
```
- [ ] **Step 5: Route them**
- [x] **Step 5: Route them**
In `admin/internal/api/routes.go`, below the existing auth routes:
@@ -232,7 +264,7 @@ In `admin/internal/api/routes.go`, below the existing auth routes:
r.POST("/auth/signup", auth.HandleSignup)
```
- [ ] **Step 6: Build**
- [x] **Step 6: Build**
```bash
sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
@@ -240,7 +272,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./...
Expected: no output.
- [ ] **Step 7: Confirm the probe and signup by hand**
- [x] **Step 7: Confirm the probe and signup by hand**
Start admin as in the spec-3 plan Task 11 steps 24, then:
@@ -256,7 +288,7 @@ curl -s -o /dev/null -w "honeypot: %{http_code}\n" -X POST localhost:8083/auth/s
Expected: `401`; then `{"kind":"staff","email":"staff@example.com","account_id":""}`; then `201` with **no** account created (confirm with `db.accounts.countDocuments({billing_email:"bot@example.com"})` returning `0`).
- [ ] **Step 8: Commit**
- [x] **Step 8: Commit**
```bash
git add admin/