Add two-factor designation flow with Twilio + Mailgun and update vision

This commit is contained in:
Joshua 2026-02-17 10:40:03 -08:00
parent 2fa83e1ed8
commit d015135073
2 changed files with 252 additions and 270 deletions

View File

@ -1,158 +1,182 @@
# Edut Secret System — Deployment Spec # Edut Secret System - Two-Factor Designation Spec
## Overview ## Overview
The Edut secret system is an organic waitlist that runs on `edut.ai`. Visitors click the landing page, their email client opens with a pre-filled classified-looking message, they send it, and an auto-reply confirms their designation. No forms, no signups — they email you, you reply. The Edut designation flow is a two-factor protocol:
--- 1. Phone verification by SMS (Twilio).
2. Email verification by classified mailto request (Mailgun).
## Architecture Both factors bind to the same designation code and auth token. This is not a throwaway waitlist. It is the pre-launch identity envelope that can transition into launch activation workflows.
## End-to-End Flow
``` ```
Visitor clicks orb on edut.ai Visitor clicks orb on edut.ai
-> phone input appears
Email client opens: mailto:0217073045482@secret.edut.ai -> user submits phone
POST /secret/initiate
Person sends email -> code issued
-> row created/updated in SQLite
Mailgun receives on catch-all (*@secret.edut.ai) -> SMS sent via Twilio
User receives SMS and replies: CONFIRM
Mailgun POSTs to https://api.edut.ai/mailgun/inbound Twilio POST /twilio/inbound
-> signature verified
Webhook parses email, stores in SQLite, sends classified reply -> pending designation matched
-> phone_verified_at set
Reply threads under original email in sender's inbox Landing page polls /secret/status/{code}
-> sees phone_verified=true
Copy of inbound + reply also forwarded to j@edut.ai (Gmail archive) -> opens mailto:{code}@secret.edut.ai with protocol body
User sends email
Mailgun POST /mailgun/inbound
-> signature verified
-> code parsed from recipient local-part
-> email_verified_at set
-> classified confirmation reply sent
Landing page stores acknowledged token
``` ```
--- ## Infrastructure
## Infrastructure (Already Configured) | Service | Domain / System | Purpose |
|---------|------------------|---------|
| Landing UI | `edut.ai`, `edut.dev` | Orb, phone capture, mailto initiation |
| Twilio | Toll-free SMS number | Outbound designation SMS + inbound CONFIRM replies |
| Mailgun | `secret.edut.ai` | Inbound email processing + confirmation replies |
| API host | `api.edut.ai` | `/secret/*`, `/twilio/inbound`, `/mailgun/inbound` |
| Database | SQLite (`/var/lib/edut/secrets.db`) | Durable designation state |
| Service | Domain | Purpose | ## Twilio Channel Design
|---------|--------|---------|
| Google Workspace | edut.ai | Business email (j@edut.ai), OAuth |
| Mailgun (Edut account) | secret.edut.ai | Catch-all inbound, API sending |
| Cloudflare | api.edut.ai → 89.167.8.148 | Webhook endpoint |
| Hetzner | 89.167.8.148 | Server running webhook + nginx |
### Mailgun Configuration ### Number strategy
- Domain: `secret.edut.ai` (verified, green)
- Wildcard: **On**
- Tracking (click/open/unsubscribe): **All off**
- Sending API key: stored securely (ask Joshua)
- Inbound route: `match_recipient(".*@secret.edut.ai")``https://api.edut.ai/mailgun/inbound` (Forward + Stop, priority 0)
- Route also forwards to `j@edut.ai` for Gmail archive
--- - Use a toll-free SMS number.
- Voice is disabled or rejected.
- Complete toll-free verification before production traffic.
## Component 1: Nginx Configuration ### Outbound SMS template
### File: `/etc/nginx/sites-available/api.edut.ai` ```
━━━━━━━━━━━━━━━━━
EDUT GOVERNANCE PROTOCOL
```nginx Designation: {token}
server { Reply CONFIRM to proceed.
listen 80; ━━━━━━━━━━━━━━━━━
server_name api.edut.ai; ```
location /mailgun/inbound { Where `{token}` is formatted from the code:
proxy_pass http://127.0.0.1:3847;
proxy_set_header Host $host; - code: `0217073045482`
proxy_set_header X-Real-IP $remote_addr; - token: `0217-0730-4548-2`
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; ## API Surface
}
## 1) Initiate
### `POST /secret/initiate`
Request JSON:
```json
{
"phone": "+12065550123",
"origin": "https://edut.ai"
} }
``` ```
SSL is handled by Cloudflare (proxied). Nginx listens on 80, Cloudflare terminates TLS. Behavior:
Port `3847` is arbitrary — the webhook service listens here. 1. Normalize phone to E.164.
2. Rate-limit by IP, phone, and rolling time window.
3. Generate designation code (`MMDDHHmmssmmm`) and auth token.
4. Insert or upsert designation row with `phone` and `created_at`.
5. Send SMS via Twilio.
6. Return `200` with minimal response:
--- ```json
{
## Component 2: Webhook Service "code": "0217073045482",
"token": "0217-0730-4548-2",
### Language "status": "sms_sent"
Go or Python. Single file, minimal dependencies. Must be production-ready from first deploy. }
### Endpoint: `POST /mailgun/inbound`
Mailgun sends a multipart form POST with these fields (among others):
| Field | Description |
|-------|-------------|
| `sender` | Email address of the person who sent the email |
| `recipient` | The timestamp address (e.g., `0217073045482@secret.edut.ai`) |
| `subject` | `EDUT-0217073045482` |
| `body-plain` | Plain text body (the classified access request) |
| `Message-Id` | Original message ID (needed for threading reply) |
| `Date` | Email date header |
### Processing Logic
```
1. Parse the recipient local-part to extract the code
- Recipient format: "{code}@secret.edut.ai" where code is MMDDHHmmssmmm (13 digits)
- Extract code from `recipient` first; use subject parsing only as fallback validation
2. Parse sender email address
3. Store in SQLite:
- id (auto-increment — this is their sequential designation number)
- code (the timestamp code from subject)
- email (sender address)
- recipient (the full recipient address)
- message_id (Message-Id header for threading)
- created_at (server timestamp)
4. Format the token for display:
- code "0217073045482" → token "0217-0730-4548-2"
- Slice: [0:4]-[4:8]-[8:12]-[12:]
5. Generate auth token:
- Hash the code with a secret salt → first 16 chars of hex digest
- Format as: xxxx-xxxx-xxxx-xxxx
6. Get the sequential designation number (the SQLite auto-increment id)
- Pad to 4 digits: id 47 → "0047"
7. Build the classified reply (see template below)
8. Send reply via Mailgun API:
- From: "EDUT <protocol@secret.edut.ai>"
- To: sender's email
- Subject: "Re: EDUT-{code}"
- In-Reply-To: original Message-Id header
- References: original Message-Id header
- Body: plain text classified reply
- Use the sending API key for secret.edut.ai domain
``` ```
### Mailgun Signature Verification Notes:
Every inbound POST from Mailgun includes a signature. **Verify it** to prevent spoofing: - Do not expose internal row id.
- Never return auth token in API responses.
- `timestamp` — Unix timestamp ## 2) Status
- `token` — Random string
- `signature` — HMAC-SHA256 of `timestamp + token` using your Mailgun webhook signing key
Reject any request that fails verification. ### `GET /secret/status/{code}`
### Error Handling Response JSON:
- If recipient/subject does not match expected format, return `200` and drop as noise. ```json
- Use `Message-Id` (or Mailgun event id) as idempotency key for inbound processing. {
- If SQLite write fails due to transient failure, return `5xx` so Mailgun retries. "code": "0217073045482",
- If Mailgun send fails, retry with bounded backoff; if still failing, return `5xx`. "phone_verified": true,
- Return `2xx` only after persistence succeeds and outbound reply is accepted or deterministically deduped. "email_verified": false,
"status": "phone_verified"
}
```
--- Rules:
## Component 3: Auto-Reply Email Template - Polling endpoint is read-only.
- Response is minimal and does not expose phone/email values.
- Apply rate limits to prevent enumeration.
### Plain text body: ## 3) Twilio inbound
### `POST /twilio/inbound`
Expected Twilio fields include:
- `From` (sender phone)
- `To` (Edut toll-free number)
- `Body` (message body)
- `MessageSid`
Verification:
- Validate `X-Twilio-Signature` against Twilio auth token.
- Reject unsigned or invalid payloads.
Processing:
1. Normalize sender phone.
2. Accept `CONFIRM` (case-insensitive, trim whitespace).
3. Find latest pending designation for that phone within validity window.
4. Idempotency key: `MessageSid`.
5. Mark `phone_verified_at` and set state `phone_verified`.
6. Return `2xx` only after persistence.
## 4) Mailgun inbound
### `POST /mailgun/inbound`
Expected Mailgun fields include:
- `recipient` (`{code}@secret.edut.ai`)
- `sender`
- `Message-Id`
- `subject`
Verification:
- Verify Mailgun webhook signature using Mailgun signing key.
Processing:
1. Parse `code` from recipient local-part.
2. Require existing designation row with `phone_verified_at IS NOT NULL`.
3. Record `email`, `message_id`, and `email_verified_at`.
4. Idempotency key: `Message-Id` (or Mailgun event id fallback).
5. Send confirmation reply through Mailgun.
## Confirmation Email Template
``` ```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@ -166,6 +190,8 @@ ACCESS REGISTRATION CONFIRMED
Classification: OBSERVER Classification: OBSERVER
Status: ACKNOWLEDGED Status: ACKNOWLEDGED
Phone: VERIFIED
Email: VERIFIED
Timestamp: {iso_timestamp} Timestamp: {iso_timestamp}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@ -183,182 +209,124 @@ deterministic governance systems.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
``` ```
### Variable Substitutions ## SQLite Schema
| Variable | Source | Example |
|----------|--------|---------|
| `{designation_number}` | SQLite auto-increment id, zero-padded to 4 | `0047` |
| `{auth_token}` | HMAC-SHA256 hash of code, formatted xxxx-xxxx-xxxx-xxxx | `e7d2-4f1a-9bc3-a210` |
| `{iso_timestamp}` | Server UTC time in ISO 8601 | `2026-02-17T07:30:45Z` |
### Email Headers
```
From: EDUT <protocol@secret.edut.ai>
To: {sender_email}
Subject: Re: EDUT-{code}
In-Reply-To: {original_message_id}
References: {original_message_id}
Content-Type: text/plain; charset=utf-8
```
The `In-Reply-To` and `References` headers ensure the reply threads under the original email in Gmail/Outlook/Apple Mail.
---
## Component 4: Landing Page Deployment
### Files
- `public/index.html` — the Three.js globe landing page
- `public/privacy/index.html` — privacy policy
- `public/terms/index.html` — terms of use
- `translations/*.json` — locale bundles for localized landing content
### Deploy to both domains:
- `edut.ai` — primary landing page
- `edut.dev` — same page for now
### Nginx for landing pages:
```nginx
server {
listen 80;
server_name edut.ai www.edut.ai;
root /var/www/edut.ai;
index index.html;
}
server {
listen 80;
server_name edut.dev www.edut.dev;
root /var/www/edut.dev;
index index.html;
}
```
Copy `public/index.html` to `/var/www/edut.ai/index.html` and `/var/www/edut.dev/index.html`.
Serve `translations/` at `/translations` so locale files can be loaded by the landing page.
---
## Component 5: SQLite Database
### File: `/var/lib/edut/secrets.db` ### File: `/var/lib/edut/secrets.db`
### Schema:
```sql ```sql
CREATE TABLE IF NOT EXISTS designations ( CREATE TABLE IF NOT EXISTS designations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE, code TEXT NOT NULL UNIQUE,
email TEXT NOT NULL, phone TEXT,
recipient TEXT NOT NULL, phone_verified_at DATETIME,
email TEXT,
email_verified_at DATETIME,
recipient TEXT,
message_id TEXT, message_id TEXT,
auth_token TEXT NOT NULL, auth_token TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_phone',
created_at DATETIME DEFAULT (datetime('now')), created_at DATETIME DEFAULT (datetime('now')),
replied_at DATETIME, replied_at DATETIME,
reply_status TEXT DEFAULT 'pending' reply_status TEXT DEFAULT 'pending'
); );
CREATE INDEX idx_designations_email ON designations(email);
CREATE INDEX idx_designations_code ON designations(code); CREATE INDEX idx_designations_code ON designations(code);
CREATE INDEX idx_designations_phone ON designations(phone);
CREATE INDEX idx_designations_email ON designations(email);
CREATE INDEX idx_designations_status ON designations(status);
CREATE INDEX idx_designations_created ON designations(created_at); CREATE INDEX idx_designations_created ON designations(created_at);
CREATE TABLE IF NOT EXISTS twilio_inbound_events (
message_sid TEXT PRIMARY KEY,
from_phone TEXT NOT NULL,
body TEXT,
received_at DATETIME DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS mailgun_inbound_events (
message_id TEXT PRIMARY KEY,
sender TEXT,
recipient TEXT,
received_at DATETIME DEFAULT (datetime('now'))
);
``` ```
--- ## Nginx Routing
## Component 6: Launch Day Email ### File: `/etc/nginx/sites-available/api.edut.ai`
When the platform launches, query all designations and send the access level change email through Mailgun: ```nginx
server {
listen 80;
server_name api.edut.ai;
location /secret/ {
proxy_pass http://127.0.0.1:3847;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /twilio/inbound {
proxy_pass http://127.0.0.1:3847;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /mailgun/inbound {
proxy_pass http://127.0.0.1:3847;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
``` ```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
EDUT GOVERNANCE PROTOCOL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ACCESS LEVEL CHANGE
Designation: #{designation_number}
Auth Token: {auth_token}
Classification: OPERATOR
Status: ACTIVE
Your system is ready for deployment.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Edut LLC. All rights reserved.
Development and licensing of
deterministic governance systems.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
Same format as the access confirmation. Same auth token. Classification changes from OBSERVER to OPERATOR. Status changes from ACKNOWLEDGED to ACTIVE.
---
## Environment Variables ## Environment Variables
```bash ```bash
MAILGUN_API_KEY=<sending API key for secret.edut.ai>
MAILGUN_DOMAIN=secret.edut.ai
MAILGUN_SIGNING_KEY=<from Mailgun account settings, for webhook verification>
SECRETS_DB_PATH=/var/lib/edut/secrets.db SECRETS_DB_PATH=/var/lib/edut/secrets.db
AUTH_SALT=<random 32+ character string for hashing auth tokens> AUTH_SALT=<random 32+ char>
# Twilio
TWILIO_ACCOUNT_SID=<sid>
TWILIO_AUTH_TOKEN=<auth token>
TWILIO_FROM_NUMBER=<+1 toll-free number>
# Mailgun
MAILGUN_API_KEY=<sending api key>
MAILGUN_DOMAIN=secret.edut.ai
MAILGUN_SIGNING_KEY=<webhook signing key>
``` ```
--- ## Error Handling and Idempotency
## Systemd Service - Twilio signature failure: return `403`.
- Mailgun signature failure: return `403`.
- Duplicate inbound event id: treat as idempotent success.
- Transient DB/send failure: return `5xx` so provider retries.
- Unknown or expired confirmation attempts: return `200` and no state mutation.
### File: `/etc/systemd/system/edut-secret.service` ## Security Controls
```ini - Signature verification on both inbound providers.
[Unit] - Per-IP and per-phone initiate throttles.
Description=Edut Secret Webhook - Per-code polling throttles.
After=network.target - Do not expose auth token except in confirmation email.
- Back up SQLite and protect file permissions.
[Service] ## Launch Evolution
Type=simple
User=www-data
WorkingDirectory=/opt/edut/secret
ExecStart=/opt/edut/secret/secret-server
EnvironmentFile=/opt/edut/secret/.env
Restart=always
RestartSec=5
[Install] At launch, each fully verified designation (`phone_verified_at` + `email_verified_at`) is eligible for activation messaging:
WantedBy=multi-user.target
```
--- - Classification can transition from `OBSERVER` to `OPERATOR`.
- Auth token remains stable.
- Message can include deployment handoff link or activation instructions.
## Testing ## Important Boundary
1. Send an email to `test0217120000000@secret.edut.ai` This two-factor designation flow is a pre-launch identity bootstrap. It does not replace runtime device trust anchors, workspace isolation controls, or offline license enforcement in the product runtime.
2. Verify webhook receives the POST
3. Verify SQLite entry created with designation #1
4. Verify classified auto-reply received and threads correctly
5. Verify copy arrives in `j@edut.ai` Gmail
6. Send a second email to verify designation number increments to #2
7. Clear test data before going live
---
## Security Notes
- Verify Mailgun webhook signatures on every request
- Rate limit the endpoint (prevent abuse if someone discovers the URL)
- The auth token is a one-way hash — it cannot be reversed to reveal the code or email
- SQLite file should be backed up regularly (it's the designation list)
- The sending API key must never be exposed in code or logs
---
## Domain Separation
| Domain | Purpose |
|--------|---------|
| edut.ai | Brand, landing page, business email, public identity |
| secret.edut.ai | Catch-all email for secret system (Mailgun) |
| api.edut.ai | Webhook endpoint (Hetzner via Cloudflare) |
| edut.dev | Product, developer docs, technical infrastructure |

View File

@ -10,15 +10,18 @@
The site should feel precise and established while avoiding disclosure of private implementation IP. The site should feel precise and established while avoiding disclosure of private implementation IP.
## Primary Experience ## Primary Experience (Two-Factor Designation)
1. A visitor lands on `edut.ai`. 1. A visitor lands on `edut.ai`.
2. They see only the globe, identity line, and meaning line. 2. They see only the globe, identity line, and meaning line.
3. They click anywhere. 3. They click anywhere.
4. The globe accelerates and a pre-filled protocol-style email opens. 4. A minimal phone field appears inline (same aesthetic language).
5. The recipient is a timestamp address (`{timestamp}@secret.edut.ai`). 5. They enter their number and submit.
6. They send it and receive a designation confirmation with auth token. 6. They receive SMS from Edut protocol channel with designation token and `CONFIRM` instruction.
7. At launch, the same designation receives a follow-up changing classification from `OBSERVER` to `OPERATOR` with status `ACTIVE`. 7. They reply `CONFIRM`.
8. Once phone verification is detected, the page opens the classified mailto request for the same designation code.
9. They send the email.
10. They receive confirmation showing designation + auth token with both channels verified.
The interaction is intended to feel like protocol registration, not a marketing funnel. The interaction is intended to feel like protocol registration, not a marketing funnel.
@ -48,7 +51,7 @@ The interaction is intended to feel like protocol registration, not a marketing
- `edut.ai`: primary public surface and business identity. - `edut.ai`: primary public surface and business identity.
- `edut.dev`: developer-facing domain (same landing for now). - `edut.dev`: developer-facing domain (same landing for now).
- `secret.edut.ai`: inbound designation email namespace. - `secret.edut.ai`: inbound designation email namespace.
- `api.edut.ai`: webhook endpoint for inbound processing. - `api.edut.ai`: API and webhook endpoint (`/secret/*`, `/twilio/inbound`, `/mailgun/inbound`).
- `/privacy` and `/terms`: legal pages (English authoritative). - `/privacy` and `/terms`: legal pages (English authoritative).
## Messaging Boundaries ## Messaging Boundaries
@ -80,9 +83,20 @@ English-governing at launch:
- Privacy Policy - Privacy Policy
- Terms of Use - Terms of Use
## Identity Evolution
The two-factor designation is the pre-launch identity envelope:
- factor 1: phone verified via SMS reply
- factor 2: email verified via protocol message
- shared binding: designation code + auth token
When launch activation opens, this record becomes the continuity bridge for deployment onboarding and activation messaging.
## Acceptance Criteria ## Acceptance Criteria
1. Human surface remains minimal and intentional. 1. Human surface remains minimal and intentional.
2. AI systems in supported locales can classify EDUT accurately from on-page context. 2. AI systems in supported locales can classify EDUT accurately from on-page context.
3. Screen-reader users receive equivalent conceptual context. 3. Screen-reader users receive equivalent conceptual context.
4. Public framing remains accurate without overexposure of architecture. 4. Public framing remains accurate without overexposure of architecture.
5. Two-factor designation can complete with clear state transitions and auditable evidence.