Registration Flow Developer Guide
⚠️ PARTIALLY SUPERSEDED (2026-04-11 / 2026-05-14 / 2026-07-16). Steps below that route through the
auth-servicecanister (role assignment,validate_session) are historical — it was decommissioned 2026-04-11 (AUTH-005.1); registration now runs throughidentity-service(POST /register) +@hello-world-co-op/authper ADR-020, with sessions in oracle-bridge PostgreSQL.think-tank-suitereferences describe Think Tank, deprecated 2026-05-14 → unified under FounderyOS. Both repositories were deleted 2026-07-16 (bl-1073, not archived). (bl-1165)
This document describes the end-to-end registration flow, including age gate verification, account creation, email verification, and membership NFT issuance with tiered membership status.
Overview
The registration flow implements a two-tier membership system:
- Registered membership: Granted after email verification (all ages 13+)
- Active membership: Granted after KYC and payment (18+ only)
This system ensures COPPA compliance while providing universal platform access and age-appropriate feature gating.
Architecture Components
Frontend Components
- marketing-suite (
/registerpage): Public registration form with age gate - dao-suite / think-tank-suite: Member dashboard for upgrade to Active
- Age gate client: Client-side DOB validation and localStorage block flag
Backend Services
- user-service canister: Account creation, email verification trigger, membership upgrade orchestration
- membership canister: NFT minting (
mint_membership_registered,upgrade_to_active) - auth-service canister: Session management, role assignment
- oracle-bridge: Payment confirmation, email delivery
Registration Flow (Step by Step)
Step 1: Age Gate Entry
Location: marketing-suite/src/pages/Register.tsx
- User visits
/register - DOB input is displayed as first field (neutral dropdowns: month, day, year)
- User enters date of birth
- Client calculates age and validates:
- Under 13: Display block screen, set
localStorage.__hw_age_block(24h TTL), show "Return to Home" button - 13-17: Reveal registration form, flag as minor (
is_minor: true) - 18+: Reveal registration form, flag as adult (
is_minor: false)
- Under 13: Display block screen, set
Client-side validation:
function calculateAge(dob: Date): number {
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const monthDiff = today.getMonth() - dob.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
age--;
}
return age;
}
// Block screen for under 13
if (age < 13) {
localStorage.setItem('__hw_age_block', JSON.stringify({
blocked_at: Date.now(),
ttl: 86400000 // 24 hours
}));
showBlockScreen();
}COPPA compliance: No PII is collected until age is verified as 13+. PII form fields are conditionally rendered only after age gate passes.
Step 2: Account Creation
Location: marketing-suite/src/pages/Register.tsx → user-service::create_account
User fills registration form:
- Full name
- Email address
- Password
- Terms of Service acceptance
Client encrypts PII with AES-256-GCM:
- Encrypts: name, email, DOB
- Uses master recovery key (env var)
- Sends ciphertext to backend
User submits form →
POST /api/auth/register(via oracle-bridge)Oracle-bridge calls
user-service::create_account:rustpub struct CreateAccountRequest { email_encrypted: String, name_encrypted: String, dob_encrypted: String, password_hash: String, tos_accepted_at: u64, }User-service validates:
- DOB decrypts and validates age >= 13 (server-side check, cannot be bypassed)
- Email not already registered
- Password meets strength requirements
User-service creates account record:
ruststruct UserAccount { principal: Principal, email_encrypted: String, name_encrypted: String, dob_encrypted: String, password_hash: String, // Argon2id is_age_verified_18_plus: bool, // Derived from DOB email_verified: bool, // false initially membership_token_id: Option<u64>, // None until email verified created_at: u64, }User-service triggers email verification:
- Generates verification token (UUID)
- Stores token with 24h expiration
- Calls oracle-bridge to send verification email
Error handling:
- Age < 13: Return
"COPPA_BLOCK"error, account not created - Email already exists: Return
"EMAIL_EXISTS"error - Validation failure: Return specific error code
Step 3: Email Verification
Location: user-service::verify_email
User clicks verification link in email:
/verify-email?token=<uuid>Frontend calls
user-service::verify_email(token)User-service validates token:
- Token exists and not expired
- Token matches user account
User-service sets
email_verified: trueUser-service triggers membership NFT minting:
rust// Call membership canister let result: Result<(Result<u64, String>,), _> = call( membership_canister_id, "mint_membership_registered", (user.principal, user.tos_accepted_at), ).await; if let Ok((Ok(token_id),)) = result { // Store token ID in user record user.membership_token_id = Some(token_id); // Add to retry queue on failure }User-service updates user record with
membership_token_id
Retry mechanism:
- If
mint_membership_registeredfails, user added to retry queue - Background task retries every 5 minutes
- Max 10 retry attempts
- After max retries, admin notification sent
Note: Registered NFTs have MembershipStatus::Registered, no expiration date.
Step 4: Membership NFT Issued (Registered Status)
Location: membership::mint_membership_registered
Membership canister receives inter-canister call from user-service
Validates caller is controller (user-service principal)
Checks user doesn't already have membership NFT
Mints ICRC-7 NFT with metadata:
rustMembershipMetadata { join_date: ic_cdk::api::time(), status: MembershipStatus::Registered, tos_accepted_at: tos_timestamp, expiration_date: 0, // No expiration for Registered is_active: false, }Returns token ID to user-service
User can now log in and access Registered features:
- Platform access
- Otter Camp
- Read-only governance (no voting)
Step 5: Upgrade to Active (18+ Only)
Location: user-service::upgrade_membership_to_active
Prerequisites:
- User must be 18+ (
is_age_verified_18_plus: true) - KYC verification approved
- Payment confirmed by oracle-bridge
Flow:
User navigates to upgrade page in member dashboard
User completes KYC verification (separate flow)
User initiates payment via Stripe
Oracle-bridge confirms payment and calls
user-service::upgrade_membership_to_active:rustpub struct UpgradeMembershipRequest { principal: Principal, kyc_proof: blob, payment_proof: blob, }User-service validates:
- User exists and has Registered membership
- User is 18+ (
is_age_verified_18_plus == true) - KYC proof valid
- Payment proof valid
User-service calls
membership::upgrade_to_active:rustlet result: Result<(Result<(), String>,), _> = call( membership_canister_id, "upgrade_to_active", (user.principal, payment_proof), ).await;Membership canister transitions status:
Registered → Active- Sets expiration date to December 31, 23:59:59 UTC
- Enables voting rights
User-service updates auth-service roles (adds
Voterif needed)
Age restriction enforcement:
- 13-17 year olds see upgrade page but cannot proceed past age check
- Frontend shows age-gated message: "You must be 18 to upgrade to Active membership"
- Backend rejects upgrade attempts with
is_age_verified_18_plus: false
Session and Membership Status Propagation
Oracle-Bridge Session Endpoint
Endpoint: GET /api/auth/session
After successful authentication, oracle-bridge returns session data including membership status:
{
"authenticated": true,
"user_id": "2vxsx-fae",
"email": "user@example.com",
"roles": ["member"],
"membership_status": "Registered" // or "Active", "Expired", "Revoked"
}Backend flow:
- Oracle-bridge validates session cookie
- Calls
auth-service::validate_sessionto get user principal - Calls
membership::get_membership_status(principal)to get current status - Returns combined session data
Frontend Membership Hook
Location: @hello-world-co-op/auth package (planned)
import { useMembership } from '@hello-world-co-op/auth';
function Dashboard() {
const { status, isActive, isRegistered, canVote } = useMembership();
if (isRegistered && !isActive) {
return <UpgradePrompt />;
}
if (isActive && canVote) {
return <VotingDashboard />;
}
}Hook reads from:
- Session data (initial load)
- Periodic refresh (every 5 minutes)
- Manual refresh after upgrade
Error Handling and Retry Logic
Email Verification Retry Queue
Location: user-service background task
If mint_membership_registered fails during email verification:
- User added to retry queue with attempt count: 0
- Background task runs every 5 minutes
- Retries minting with exponential backoff
- Max 10 attempts
- After max retries, admin notification sent via oracle-bridge
Admin notification:
{
"type": "membership_mint_failed",
"user_principal": "2vxsx-fae",
"email_encrypted": "...",
"attempts": 10,
"last_error": "Membership canister unavailable"
}Upgrade Failure Handling
If upgrade_to_active fails:
- Payment is held in escrow (not refunded automatically)
- User sees error message: "Upgrade failed, please contact support"
- Support team manually triggers retry or issues refund
- No automatic retry for upgrades (financial transaction involved)
Database Schema
user-service State
struct State {
users: HashMap<Principal, UserAccount>,
email_to_principal: HashMap<String, Principal>, // Encrypted email -> Principal
verification_tokens: HashMap<String, VerificationToken>,
membership_retry_queue: Vec<RetryQueueItem>,
}
struct UserAccount {
principal: Principal,
email_encrypted: String,
name_encrypted: String,
dob_encrypted: String,
password_hash: String, // Argon2id
is_age_verified_18_plus: bool,
email_verified: bool,
kyc_verified: bool,
membership_token_id: Option<u64>,
created_at: u64,
updated_at: u64,
}
struct VerificationToken {
user_principal: Principal,
token: String, // UUID
expires_at: u64,
}
struct RetryQueueItem {
user_principal: Principal,
operation: RetryOperation, // MintRegistered | UpgradeToActive
attempts: u8,
last_attempt: u64,
}membership State
struct State {
memberships: HashMap<Principal, MembershipToken>,
token_id_counter: u64,
}
struct MembershipToken {
token_id: u64,
owner: Principal,
metadata: MembershipMetadata,
}
struct MembershipMetadata {
join_date: u64,
status: MembershipStatus,
tos_accepted_at: u64,
expiration_date: u64, // 0 for Registered, Dec 31 for Active
is_active: bool,
}
enum MembershipStatus {
Active, // Full voting member
Registered, // Limited member (no voting)
Expired, // Past expiration (Active → Expired)
Revoked, // Admin revoked
}Testing Considerations
Integration Test Scenarios
Happy path (18+):
- Register with DOB showing 18+
- Verify email
- Receive Registered NFT
- Complete KYC
- Pay dues
- Upgrade to Active
- Verify voting enabled
Happy path (13-17):
- Register with DOB showing 13-17
- Verify email
- Receive Registered NFT
- Attempt upgrade (should fail with age error)
COPPA block (under 13):
- Attempt registration with DOB under 13
- Verify block screen shown
- Verify no account created
- Verify localStorage block flag set
Email verification retry:
- Simulate membership canister failure during verification
- Verify user added to retry queue
- Verify retry succeeds on next attempt
Upgrade failure:
- User pays but upgrade fails
- Verify payment held
- Verify support notification sent
Unit Test Coverage
- Age calculation edge cases (leap years, timezone handling)
- Encryption/decryption of PII
- Token expiration validation
- Retry queue behavior (max attempts, backoff)
- Membership status transitions
Security Considerations
PII Encryption
- Master recovery key: Stored in environment variable, rotated quarterly
- Algorithm: AES-256-GCM
- Scope: name, email, DOB
- Decryption: Only user-service and admin tools have access
DOB as Derived Boolean
- Raw DOB: Encrypted in
dob_encrypted, never exposed to frontend - Derived flag:
is_age_verified_18_plusboolean cached for access control - Why?: Avoids exposing actual age/DOB to frontend or other canisters
Age Verification Anti-Circumvention
- Client-side block:
localStorage.__hw_age_blockdeters casual re-attempts (24h TTL) - Server-side enforcement:
validate_age_from_dob()in user-service is authoritative - Cannot bypass: Client cannot manipulate server-side DOB validation
Session Security
- Cookie domain:
.helloworlddao.com(leading dot for subdomain SSO) - HttpOnly: True (not accessible via JavaScript)
- Secure: True (HTTPS only)
- SameSite: Lax (allows cross-subdomain navigation)
Related Documentation
- FAS Architecture - Frontend application split overview
- User Guide: Getting Started - End-user registration guide
- Membership API - Membership canister API reference
Deployment Checklist
Before deploying registration changes:
- [ ] Update environment variables (
MASTER_RECOVERY_KEYrotated) - [ ] Verify oracle-bridge email templates include verification link
- [ ] Test retry queue behavior in staging
- [ ] Verify membership canister has sufficient cycles
- [ ] Confirm auth-service role propagation working
- [ ] Test age gate with various DOB values (edge cases)
- [ ] Verify localStorage block flag TTL working
- [ ] Test upgrade flow with Stripe sandbox
- [ ] Verify COPPA block screen displays correctly
- [ ] Test cross-suite navigation with Registered vs Active status