Puzzlum Chat Account System

Internal developer documentation for the account/authentication portion of Puzzlum Chat: endpoints, flows, file formats, and key security behaviors.

Scope: Accounts/Auth Storage: JSON files Captcha: PHP session Cookie: chat_token

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.

Key properties
  • 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']}.test identity endpoints (Admin)

2. Directory layout

Public entrypoint
/public/Chat/auth.php
Private handler
/{$ink['flavor']}-private/Chat/php/auth.php
Core library
/{$ink['flavor']}-private/Chat/php/lib.php
Config (constants)
/{$ink['flavor']}-private/Chat/php/config.php
JSON data root
/{$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
The library also defines paths for additional chat features (rooms/messages/memberships/etc.), but those are outside the strict “account system” scope. They share the same JSON-write primitives and error contract.

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"]
}
Important behavior: The override is intentionally narrow so other Chat actions (register/recover/etc.) can still function on the dev host if desired, while identity checks remain stable and predictable.

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
Unknown actions return 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..."}  |
Leak prevention: 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.

Concurrency expectation: This is designed for light-to-moderate traffic. Under heavy write contention, JSON-file storage can become a bottleneck and may require migration to a DB.

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 ip is non-empty, it must match current REMOTE_ADDR.
  • If stored ua is non-empty, it must match current HTTP_USER_AGENT (truncated to 200 chars on store).
Tradeoff: IP binding can cause false logouts for users on mobile networks or behind changing proxies. If you see that behavior in the wild, consider disabling strict IP binding or replacing it with a softer heuristic.

7.3 Cookie settings

Cookie name
chat_token
Path
/ (site-wide)
Expires
now + CHAT_TOKEN_TTL (default: 7 days)
HttpOnly
true
SameSite
Lax
Secure
false by default (should be true when HTTPS-only is guaranteed)
Hardening recommendation: If your production site is HTTPS (it should be), set 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.

Because captcha uses PHP sessions, ensure sessions are working on the host (session save path writable, cookies enabled). If sessions are unreliable on a given host, captcha verification will fail even with correct answers.

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 / 409 with { ok:false, error }
  • On “not logged in”: HTTP 200, JSON: { ok:true, loggedIn:false } for me
{ "ok": false, "error": "Invalid username or password." }
Client guidance: Treat non-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 = true when 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 last usage timestamp.
  • Audit logging: Record login failures/successes and recovery events (with privacy-safe redaction).
Operational caveat: JSON-file storage is simple and portable but not ideal for high concurrency. If you scale up, migrating tokens/users to a database will improve safety and performance.

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 me and treat loggedIn:false as “no session”.
  • Server-side calls to me will 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.
If you add privileged access to user private storage (e.g., /{$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 }
Tip: If you are testing via server-to-server tooling, remember cookies do not “magically” exist. You must capture and resend the chat_token cookie for me and logout tests.