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
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
Email client opens: mailto:0217073045482@secret.edut.ai
Person sends email
Mailgun receives on catch-all (*@secret.edut.ai)
Mailgun POSTs to https://api.edut.ai/mailgun/inbound
Webhook parses email, stores in SQLite, sends classified reply
Reply threads under original email in sender's inbox
Copy of inbound + reply also forwarded to j@edut.ai (Gmail archive)
-> phone input appears
-> user submits phone
POST /secret/initiate
-> code issued
-> row created/updated in SQLite
-> SMS sent via Twilio
User receives SMS and replies: CONFIRM
Twilio POST /twilio/inbound
-> signature verified
-> pending designation matched
-> phone_verified_at set
Landing page polls /secret/status/{code}
-> sees phone_verified=true
-> 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 |
|---------|--------|---------|
| 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 |
## Twilio Channel Design
### Mailgun Configuration
- 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
### Number strategy
---
- 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
server {
listen 80;
server_name api.edut.ai;
Designation: {token}
Reply CONFIRM to proceed.
━━━━━━━━━━━━━━━━━
```
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;
}
Where `{token}` is formatted from the code:
- code: `0217073045482`
- token: `0217-0730-4548-2`
## 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:
---
## Component 2: Webhook Service
### Language
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
```json
{
"code": "0217073045482",
"token": "0217-0730-4548-2",
"status": "sms_sent"
}
```
### 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
- `token` — Random string
- `signature` — HMAC-SHA256 of `timestamp + token` using your Mailgun webhook signing key
## 2) Status
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.
- 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.
- If Mailgun send fails, retry with bounded backoff; if still failing, return `5xx`.
- Return `2xx` only after persistence succeeds and outbound reply is accepted or deterministically deduped.
```json
{
"code": "0217073045482",
"phone_verified": true,
"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
Status: ACKNOWLEDGED
Phone: VERIFIED
Email: VERIFIED
Timestamp: {iso_timestamp}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@ -183,182 +209,124 @@ deterministic governance systems.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
### Variable Substitutions
| 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
## SQLite Schema
### File: `/var/lib/edut/secrets.db`
### Schema:
```sql
CREATE TABLE IF NOT EXISTS designations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
recipient TEXT NOT NULL,
phone TEXT,
phone_verified_at DATETIME,
email TEXT,
email_verified_at DATETIME,
recipient TEXT,
message_id TEXT,
auth_token TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_phone',
created_at DATETIME DEFAULT (datetime('now')),
replied_at DATETIME,
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_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 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
```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
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
[Unit]
Description=Edut Secret Webhook
After=network.target
- Signature verification on both inbound providers.
- Per-IP and per-phone initiate throttles.
- Per-code polling throttles.
- Do not expose auth token except in confirmation email.
- Back up SQLite and protect file permissions.
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/edut/secret
ExecStart=/opt/edut/secret/secret-server
EnvironmentFile=/opt/edut/secret/.env
Restart=always
RestartSec=5
## Launch Evolution
[Install]
WantedBy=multi-user.target
```
At launch, each fully verified designation (`phone_verified_at` + `email_verified_at`) is eligible for activation messaging:
---
- 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`
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 |
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.

View File

@ -10,15 +10,18 @@
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`.
2. They see only the globe, identity line, and meaning line.
3. They click anywhere.
4. The globe accelerates and a pre-filled protocol-style email opens.
5. The recipient is a timestamp address (`{timestamp}@secret.edut.ai`).
6. They send it and receive a designation confirmation with auth token.
7. At launch, the same designation receives a follow-up changing classification from `OBSERVER` to `OPERATOR` with status `ACTIVE`.
4. A minimal phone field appears inline (same aesthetic language).
5. They enter their number and submit.
6. They receive SMS from Edut protocol channel with designation token and `CONFIRM` instruction.
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.
@ -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.dev`: developer-facing domain (same landing for now).
- `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).
## Messaging Boundaries
@ -80,9 +83,20 @@ English-governing at launch:
- Privacy Policy
- 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
1. Human surface remains minimal and intentional.
2. AI systems in supported locales can classify EDUT accurately from on-page context.
3. Screen-reader users receive equivalent conceptual context.
4. Public framing remains accurate without overexposure of architecture.
5. Two-factor designation can complete with clear state transitions and auditable evidence.