1. Overview
The Chat Account System provides registration (with email confirmation), login/logout, session verification
(me), confirmation resend, and password recovery. It stores state as JSON files under
/{$ink['flavor']}-private/Chat/json and exposes a single public JSON endpoint:
/public/Chat/auth.php which includes the private handler:
/{$ink['flavor']}-private/Chat/php/auth.php.
- Single public auth endpoint with action routing via
?action=... - JSON-only responses (including errors/fatals) to keep clients stable
- Cookie-based auth using an opaque token stored server-side in
tokens.json - Dev host override for
{$ink['flavor']}.{$ink['version']}.testidentity endpoints (Admin)
2. Directory layout
/public/Chat/auth.php/{$ink['flavor']}-private/Chat/php/auth.php/{$ink['flavor']}-private/Chat/php/lib.php/{$ink['flavor']}-private/Chat/php/config.php/{$ink['flavor']}-private/Chat/json/2.1 JSON files used by accounts
| File | Purpose | Owned by |
|---|---|---|
users.json |
User records (username, email, passhash, confirmation state, timestamps) | Auth handler |
pending.json |
Registration confirmation tokens (10 minute TTL) | Auth handler |
recover.json |
Password reset tokens (15 minute TTL) | Auth handler |
tokens.json |
Issued auth tokens (cookie values) with expiry + IP/UA binding | Auth handler / lib |
3. Public entrypoints & dev override
3.1 /public/Chat/auth.php is a thin shim
The public endpoint sets JSON headers, installs JSON error handlers, resolves the private chat root,
and includes /{$ink['flavor']}-private/Chat/php/auth.php. All real logic lives in private code.
3.2 Dev host override: {$ink['flavor']}.{$ink['version']}.test
When HTTP_HOST (port-stripped) equals {$ink['flavor']}.{$ink['version']}.test, the entrypoint intercepts only
identity endpoints:
?action=me(or empty action)?action=logout
It returns a deterministic Admin identity without needing cookies/sessions, enabling local dev flows and preventing accidental dependency on remote accounts.
{
"ok": true,
"loggedIn": true,
"username": "admin",
"displayName": "Admin",
"admin": true,
"roles": ["admin"]
}
4. Auth API (actions)
The private handler routes by $_GET["action"]. JSON request bodies are read from
php://input and decoded if present.
| Action | Method | Input | Success output (shape) | Notes |
|---|---|---|---|---|
captcha |
GET | — | { ok:true, question:"A + B = ?" } |
Stores answer in PHP session; expires quickly |
register |
POST | { username, email, password, captcha } |
{ ok:true, message } |
Creates unconfirmed user + pending token; emails activation link |
confirm |
GET | ?token=... |
{ ok:true, username, displayName, email, message } |
Marks user confirmed; deletes token |
login |
POST | { username, password } |
{ ok:true, loggedIn:true, username, displayName, email, tokenSet:true } |
Issues token + sets chat_token cookie |
logout |
POST | {} |
{ ok:true, loggedIn:false } |
Revokes token, clears cookie |
me |
GET | Cookie: chat_token |
{ ok:true, loggedIn:true, username, displayName, email } |
Verifies token; returns identity or loggedIn:false |
resend |
POST | { email, captcha } |
{ ok:true, message } |
Does not leak existence; reissues confirmation for unconfirmed accounts |
recover_request |
POST | { email, captcha } |
{ ok:true, message } |
Does not leak existence; emails reset link for confirmed accounts |
recover_reset |
POST | { token, password } |
{ ok:true, message } |
Updates passhash; deletes recovery token |
400 with { ok:false, error:"Unknown action." }.
5. Core flows
5.1 Registration + email confirmation
Client /Chat/auth.php /{$ink['flavor']}-private/Chat/php/auth.php JSON storage
| POST action=register {user,email,pass,captcha} | |
|---------------------------------------------------------->| validate username/email/pass/captcha |
| | create users[username] (confirmed=false) |
| | create pending[token]={user,email,exp} |
| | write users.json + pending.json |
| | email activate.html?token=... |
|<----------------------------------------------------------| {ok:true, message} |
|
| GET action=confirm&token=... |
|---------------------------------------------------------->| lookup pending[token], set confirmed=true |
| | pending[token] removed |
| | write users.json + pending.json |
|<----------------------------------------------------------| {ok:true, message:"Account activated..."} |
resend and recover_request always return success text regardless of whether the
email exists, reducing account enumeration.
5.2 Login + token cookie issuance
Client /Chat/auth.php /{$ink['flavor']}-private/Chat/php/auth.php tokens.json
| POST action=login {username,password} | |
|------------------------------------------------------------>| find users[username] |
| | require confirmed==true |
| | verify password_hash |
| | token = chat_issue_token(username) |
| | setcookie(chat_token=token, opts...) |
|<------------------------------------------------------------| {ok:true, loggedIn:true, tokenSet:true}|
| |
| GET action=me (cookie chat_token) |
|------------------------------------------------------------>| chat_verify_token(token) -> true? |
| | lookup user record |
|<------------------------------------------------------------| {ok:true, loggedIn:true, identity...} |
5.3 Logout
Client -> POST action=logout
- reads cookie chat_token
- deletes token from tokens.json (if present)
- expires cookie
- returns {ok:true, loggedIn:false}
5.4 Password recovery
Client auth.php (recover_request) storage
| POST {email,captcha} -> create recover[token]={user,email,exp(15m)} -> recover.json
| (email reset link with token)
|
Client auth.php (recover_reset)
| POST {token,newPassword} -> validate -> users[user].passhash updated -> token removed
6. Storage schema
6.1 users.json
Users are stored in an object keyed by lowercase username (canonical). The record also
stores a presentation-only displayName.
{
"joy": {
"username": "joy",
"displayName": "Joy",
"email": "example@domain.tld",
"passhash": "$2y$10$...",
"confirmed": true,
"created": "2026-02-02T23:12:34Z",
"confirmedAt": "2026-02-02T23:15:01Z"
}
}
6.2 pending.json (confirmation tokens)
{
"AbCdEf...": { "user":"joy", "email":"example@domain.tld", "exp": 1760000000 }
}
6.3 recover.json (password reset tokens)
{
"XyZ...": { "user":"joy", "email":"example@domain.tld", "exp": 1760000000 }
}
6.4 JSON write strategy (atomic-ish)
Writes are performed via a temp file + exclusive lock and then renamed into place.
If rename() fails (host FS quirks), a copy+unlink fallback is used.
7. Token model & cookie behavior
7.1 Token issuance and storage (tokens.json)
On successful login, the server generates an opaque token and writes a record to tokens.json.
The record includes expiry, and a “mild binding” to the client IP and User-Agent:
{
"tokenString": {
"user": "joy",
"exp": 1760000000,
"ip": "203.0.113.10",
"ua": "Mozilla/5.0 ...",
"last": 0
}
}
7.2 Verification behavior
- Reject if token missing, not found, or expired.
- If stored
ipis non-empty, it must match currentREMOTE_ADDR. - If stored
uais non-empty, it must match currentHTTP_USER_AGENT(truncated to 200 chars on store).
7.3 Cookie settings
chat_token/ (site-wide)now + CHAT_TOKEN_TTL (default: 7 days)trueLaxfalse by default (should be true when HTTPS-only is guaranteed)CHAT_COOKIE_SECURE = true
so the token never travels over plaintext HTTP.
8. Captcha model
Captcha is a simple arithmetic challenge stored in a PHP session. The endpoint
?action=captcha returns a question like "3 + 8 = ?" and writes:
$_SESSION["chat_captcha_answer"]$_SESSION["chat_captcha_ts"]
Captcha expires after ~5 minutes. Registration, resend, and recover_request require a valid captcha answer.
9. Error handling contract
Both the public entrypoint and private handler install JSON-only error/exception/shutdown handlers so clients never get HTML error pages. The contract is:
- On server error: HTTP
500, JSON:{ ok:false, error:"..." } - On client validation error: typically
400/401/403/409with{ ok:false, error } - On “not logged in”: HTTP
200, JSON:{ ok:true, loggedIn:false }forme
{ "ok": false, "error": "Invalid username or password." }
ok or HTTP status >= 400 as user-facing errors; treat loggedIn:false
as an authentication state (not necessarily a failure).
10. Security notes & hardening checklist
10.1 What’s already present
- Passwords hashed using
password_hash(..., PASSWORD_DEFAULT) - Account confirmation required before login
- Enumeration resistance for resend/recovery flows
- HttpOnly cookie reduces XSS token theft surface
- JSON-only error responses reduce fragile client parsing issues
10.2 Recommended improvements (production)
- Set Secure cookies:
CHAT_COOKIE_SECURE = truewhen HTTPS-only - CSRF posture: SameSite=Lax helps, but consider CSRF tokens for state-changing POSTs
- Rate limits:
Implement per-IP and per-username throttling on
login, and per-email throttling on resend/recover. (A constant exists for chat posting cadence, but login is not currently throttled.) - Token rotation:
Optionally rotate token periodically (sliding sessions) and store
lastusage timestamp. - Audit logging: Record login failures/successes and recovery events (with privacy-safe redaction).
11. Integration points (Guardian/Ink)
Chat accounts act as the shared identity layer for other Puzzlum subsystems. The canonical way for other apps to check identity is to call:
GET /Chat/auth.php?action=me
-> { ok:true, loggedIn:true, username, displayName, email }
Design expectations for integrators:
- Client-side apps (e.g., Guardian frontend) should rely on
meand treatloggedIn:falseas “no session”. - Server-side calls to
mewill not carry the browser’s cookie automatically. If you need server-side identity, you must pass the cookie through explicitly (or avoid server-side identity checks altogether for endpoints that are meant for browser use). - Dev host override ensures that local development does not depend on remote account state.
/{$ink['flavor']}-private/users/<username>),
ensure that check happens server-side by verifying the token cookie (or by proxying through Chat auth) rather
than trusting client-provided usernames.
12. Testing recipes
12.1 Smoke-test: identity
# Identity (browser cookie required for real hosts)
curl -i "https://puzzlum.org/Chat/auth.php?action=me"
# Dev host should always be Admin for ?action=me
curl -i "http://{$ink['flavor']}.{$ink['version']}.test/Chat/auth.php?action=me"
12.2 Register (requires captcha)
1) GET captcha:
GET /Chat/auth.php?action=captcha
-> { ok:true, question:"X + Y = ?" }
2) POST register with the computed answer:
POST /Chat/auth.php?action=register
body: { "username":"Joy", "email":"...", "password":"...", "captcha":"(X+Y)" }
12.3 Confirm
GET /Chat/auth.php?action=confirm&token=... (token from email link)
-> { ok:true, message:"Account activated..." }
12.4 Login / Logout
POST /Chat/auth.php?action=login
body: { "username":"joy", "password":"..." }
-> sets Set-Cookie: chat_token=...
GET /Chat/auth.php?action=me (with cookie)
-> { loggedIn:true, ... }
POST /Chat/auth.php?action=logout (with cookie)
-> { loggedIn:false }
chat_token cookie for me and logout tests.