Skip to content

Checking access...

Registration Flow Developer Guide ​

⚠️ PARTIALLY SUPERSEDED (2026-04-11 / 2026-05-14 / 2026-07-16). Steps below that route through the auth-service canister (role assignment, validate_session) are historical — it was decommissioned 2026-04-11 (AUTH-005.1); registration now runs through identity-service (POST /register) + @hello-world-co-op/auth per ADR-020, with sessions in oracle-bridge PostgreSQL. think-tank-suite references 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:

  1. Registered membership: Granted after email verification (all ages 13+)
  2. 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 (/register page): 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

  1. User visits /register
  2. DOB input is displayed as first field (neutral dropdowns: month, day, year)
  3. User enters date of birth
  4. 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)

Client-side validation:

typescript
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

  1. User fills registration form:

    • Full name
    • Email address
    • Password
    • Terms of Service acceptance
  2. Client encrypts PII with AES-256-GCM:

    • Encrypts: name, email, DOB
    • Uses master recovery key (env var)
    • Sends ciphertext to backend
  3. User submits form → POST /api/auth/register (via oracle-bridge)

  4. Oracle-bridge calls user-service::create_account:

    rust
    pub struct CreateAccountRequest {
        email_encrypted: String,
        name_encrypted: String,
        dob_encrypted: String,
        password_hash: String,
        tos_accepted_at: u64,
    }
  5. User-service validates:

    • DOB decrypts and validates age >= 13 (server-side check, cannot be bypassed)
    • Email not already registered
    • Password meets strength requirements
  6. User-service creates account record:

    rust
    struct 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,
    }
  7. 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

  1. User clicks verification link in email: /verify-email?token=<uuid>

  2. Frontend calls user-service::verify_email(token)

  3. User-service validates token:

    • Token exists and not expired
    • Token matches user account
  4. User-service sets email_verified: true

  5. User-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
    }
  6. User-service updates user record with membership_token_id

Retry mechanism:

  • If mint_membership_registered fails, 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

  1. Membership canister receives inter-canister call from user-service

  2. Validates caller is controller (user-service principal)

  3. Checks user doesn't already have membership NFT

  4. Mints ICRC-7 NFT with metadata:

    rust
    MembershipMetadata {
        join_date: ic_cdk::api::time(),
        status: MembershipStatus::Registered,
        tos_accepted_at: tos_timestamp,
        expiration_date: 0,  // No expiration for Registered
        is_active: false,
    }
  5. Returns token ID to user-service

  6. 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:

  1. User navigates to upgrade page in member dashboard

  2. User completes KYC verification (separate flow)

  3. User initiates payment via Stripe

  4. Oracle-bridge confirms payment and calls user-service::upgrade_membership_to_active:

    rust
    pub struct UpgradeMembershipRequest {
        principal: Principal,
        kyc_proof: blob,
        payment_proof: blob,
    }
  5. User-service validates:

    • User exists and has Registered membership
    • User is 18+ (is_age_verified_18_plus == true)
    • KYC proof valid
    • Payment proof valid
  6. User-service calls membership::upgrade_to_active:

    rust
    let result: Result<(Result<(), String>,), _> = call(
        membership_canister_id,
        "upgrade_to_active",
        (user.principal, payment_proof),
    ).await;
  7. Membership canister transitions status:

    • Registered → Active
    • Sets expiration date to December 31, 23:59:59 UTC
    • Enables voting rights
  8. User-service updates auth-service roles (adds Voter if 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:

json
{
  "authenticated": true,
  "user_id": "2vxsx-fae",
  "email": "user@example.com",
  "roles": ["member"],
  "membership_status": "Registered"  // or "Active", "Expired", "Revoked"
}

Backend flow:

  1. Oracle-bridge validates session cookie
  2. Calls auth-service::validate_session to get user principal
  3. Calls membership::get_membership_status(principal) to get current status
  4. Returns combined session data

Frontend Membership Hook ​

Location: @hello-world-co-op/auth package (planned)

typescript
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:

  1. User added to retry queue with attempt count: 0
  2. Background task runs every 5 minutes
  3. Retries minting with exponential backoff
  4. Max 10 attempts
  5. After max retries, admin notification sent via oracle-bridge

Admin notification:

json
{
  "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:

  1. Payment is held in escrow (not refunded automatically)
  2. User sees error message: "Upgrade failed, please contact support"
  3. Support team manually triggers retry or issues refund
  4. No automatic retry for upgrades (financial transaction involved)

Database Schema ​

user-service State ​

rust
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 ​

rust
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 ​

  1. Happy path (18+):

    • Register with DOB showing 18+
    • Verify email
    • Receive Registered NFT
    • Complete KYC
    • Pay dues
    • Upgrade to Active
    • Verify voting enabled
  2. Happy path (13-17):

    • Register with DOB showing 13-17
    • Verify email
    • Receive Registered NFT
    • Attempt upgrade (should fail with age error)
  3. COPPA block (under 13):

    • Attempt registration with DOB under 13
    • Verify block screen shown
    • Verify no account created
    • Verify localStorage block flag set
  4. Email verification retry:

    • Simulate membership canister failure during verification
    • Verify user added to retry queue
    • Verify retry succeeds on next attempt
  5. 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_plus boolean 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_block deters 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)

Deployment Checklist ​

Before deploying registration changes:

  • [ ] Update environment variables (MASTER_RECOVERY_KEY rotated)
  • [ ] 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

Hello World DAO