Skip to content

Checking access...

Frontend Application Split (FAS) Architecture

This document describes the architecture of the Frontend Application Split project, which decomposes the monolithic frontend into independently deployable suites consuming shared packages.

Table of Contents

Overview

The FAS project replaces the single-repo frontend monolith with:

  • 3 shared packages published to GitHub Packages (@hello-world-co-op/api, auth, ui)
  • 6 independent suite repositories that consume these packages and deploy to IC asset canisters
  • Reusable CI/CD workflows in the .github org repo for consistent build/test/deploy pipelines

Each suite is a standalone React + Vite application that deploys to its own Internet Computer asset canister with a custom domain on .helloworlddao.com.

Total test coverage across all suites: 8,628 tests (0 failures, 0 skipped).

Package Architecture

@hello-world-co-op/api (v0.1.0)

IC canister client utilities. Provides createActor() factory, ApiResponse<T> type, ERROR_CODES, and checkServiceHealth().

  • Source: api repo
  • Dependencies: @dfinity/agent, @dfinity/candid, @dfinity/principal
  • Consumers: auth, ui, think-tank-suite, dao-suite, dao-admin-suite

@hello-world-co-op/auth (v0.2.0)

Authentication context, hooks, and RBAC. Provides AuthProvider, useAuth(), ProtectedRoute, RoleGuard, useRoles(), and token storage utilities.

  • Source: auth repo
  • Dependencies: @hello-world-co-op/api
  • Consumers: ui, think-tank-suite, dao-suite, dao-admin-suite

@hello-world-co-op/ui (v0.1.0)

Shared UI component library. Provides Button, Card, Modal, Input, Select, ErrorBoundary, cn() utility, and a Tailwind base configuration.

  • Source: ui repo
  • Dependencies: @hello-world-co-op/auth, tailwindcss, @radix-ui/*
  • Consumers: think-tank-suite, governance-suite, marketing-suite, dao-suite, dao-admin-suite

Known limitation: Packages use @dfinity v2. Suites may need v3 upgrade (tracked as deferred-dfinity-v3-upgrade).

Suite Architecture

Each suite follows the structure established by the suite-template:

<suite-name>/
├── src/
│   ├── main.tsx              # Entry point
│   ├── App.tsx               # Root component (ErrorBoundary, Router)
│   ├── components/           # Suite-specific components
│   ├── pages/                # Route-level pages
│   ├── hooks/                # Custom React hooks
│   ├── services/             # API clients and service layer
│   ├── stores/               # State management (nanostores)
│   ├── utils/                # Utility functions
│   └── types/                # TypeScript type definitions
├── e2e/                      # Playwright E2E tests
├── .github/workflows/        # CI/CD (ci.yml, deploy-staging.yml)
├── package.json              # Consumes @hello-world-co-op/*
├── vite.config.ts            # Vite build configuration
├── tailwind.config.js        # Extends @hello-world-co-op/ui config
├── dfx.json                  # IC asset canister config
├── .npmrc                    # GitHub Packages registry
└── .env.example              # Environment variable documentation

All Deployed Suites

SuiteCanister IDStaging DomainProduction DomainTestsBundle SizePattern
think-tank-suitewnfjk-biaaa-aaaao-a6dhq-caistaging-think-tank.helloworlddao.comthink-tank.helloworlddao.com4,642--Auth Bridge
governance-suitewkep6-mqaaa-aaaao-a6dha-caistaging-governance.helloworlddao.comgovernance.helloworlddao.com936--Auth Direct
marketing-suited5fe6-hqaaa-aaaao-a6t5q-caistaging.helloworlddao.comwww.helloworlddao.com83358KBStandalone
otter-camp-suitedzt3i-sqaaa-aaaao-a6uaa-caistaging-ottercamp.helloworlddao.comottercamp.helloworlddao.com2,12258KB initialStandalone
dao-suited6s54-7iaaa-aaaao-a6uaq-caistaging-portal.helloworlddao.comportal.helloworlddao.com806348KBAuth Bridge
dao-admin-suitedxrwa-jaaaa-aaaao-a6uba-caistaging-admin.helloworlddao.comadmin.helloworlddao.com11593KBAuth Bridge

Suite Patterns

Suites follow one of three authentication patterns:

Pattern 1: Auth-Integrated with Bridge (think-tank, dao, dao-admin)

Uses AuthProviderBridge to wrap nanostores state into the @hello-world-co-op/auth shared interface.

Consumes: @hello-world-co-op/ui, auth, apiRequires: oracle-bridge for cookie-based SSO

AuthProviderBridge → useAuth() hook
  ↓ reads from
nanostores ($isAuthenticated, $user, $isLoading)
  ↓ calls
authStore (login, logout, refreshSession)
  ↓ talks to
auth-service canister (via oracle-bridge cookie proxy)
  • AuthProviderBridge wraps the app root, providing @hello-world-co-op/auth's AuthContextValue backed by nanostores
  • useAuth() hook reads from nanostores and returns the shared interface
  • apiHelpers.ts re-exports createActor, ApiResponse, etc. from @hello-world-co-op/api
  • getAuthHeader() is cookie-auth aware (checks VITE_USE_COOKIE_AUTH)
  • Cookie SSO populates localStorage via authCookieClient bridge for legacy components

Suites using this pattern:

  • think-tank-suite (4,642 tests) -- full productivity app with Mission Control, Workspace, Chat, Fleet, Backlog, Capture, Settings
  • dao-suite (806 tests) -- member dashboard with proposals, voting, treasury, membership, token balance
  • dao-admin-suite (115 tests) -- admin dashboard with KYC review, member management, system monitoring. Adds AdminGuard RBAC component and admin-specific validateReturnUrl

Pattern 2: Auth-Integrated Direct (governance)

Uses nanostores directly (no bridge). Cookie-based auth via authStore.

Consumes: @hello-world-co-op/ui only Auth: Cookie-based session tokens validated against auth-service canister

nanostores authStore
  ↓ manages
cookie-based session tokens
  ↓ validates against
auth-service canister
  • ErrorBoundary from @hello-world-co-op/ui wraps the app
  • Auth state managed via nanostores (no AuthProvider bridge)
  • Build-time auth bypass prevention: vite.config.ts rejects VITE_DEV_AUTH_BYPASS in production

Suites using this pattern:

  • governance-suite (936 tests: 867 unit + 69 E2E) -- proposals, voting, discussions, deep links

Pattern 3: Standalone (marketing, otter-camp)

No auth dependency. May consume @hello-world-co-op/ui for shared components or use no shared packages at all. Independent functionality.

Consumes: @hello-world-co-op/ui (marketing) or no shared packages (otter-camp) Auth: None required

Suites using this pattern:

  • marketing-suite (83 tests) -- public marketing site with SEO pre-rendering, sitemap.xml, react-helmet-async, i18n (4 languages), client-side PII encryption
  • otter-camp-suite (2,122 tests: 2,115 unit + 7 E2E) -- Phaser.js game with character creation, camp exploration, multiplayer presence, combat system. Game reads user_data from localStorage passively (read-only, set by auth suites). No IC canister calls from game code.

When to Use Each Pattern

ScenarioPatternExample
Suite needs user login, session management, protected routesAuth Bridgedao-suite
Suite only needs to read auth state (cookie)Auth Directgovernance-suite
Suite is public-facing, no user-specific contentStandalonemarketing-suite
Suite has its own rendering engine (game, canvas)Standaloneotter-camp-suite
Suite needs admin-level RBACAuth Bridge + AdminGuarddao-admin-suite

Dependency Diagram

Suite and Package Dependencies

mermaid
graph TD
    subgraph "Auth-Integrated Suites (Bridge Pattern)"
        FOS["think-tank-suite<br/>4,642 tests"]
        DAO["dao-suite<br/>806 tests"]
        ADMIN["dao-admin-suite<br/>115 tests"]
    end

    subgraph "Auth-Integrated Suite (Direct Pattern)"
        GOV["governance-suite<br/>936 tests"]
    end

    subgraph "Standalone Suites"
        MKT["marketing-suite<br/>83 tests"]
        OC["otter-camp-suite<br/>2,122 tests"]
    end

    subgraph "Shared Packages (GitHub Packages)"
        UI["@hello-world-co-op/ui<br/>v0.1.0"]
        AUTH["@hello-world-co-op/auth<br/>v0.2.0"]
        API["@hello-world-co-op/api<br/>v0.1.0"]
    end

    subgraph "IC Canisters (Backend)"
        AS[auth-service]
        US[user-service]
        GOVCAN[governance]
        TRES[treasury]
    end

    subgraph "Off-Chain Services"
        OB["oracle-bridge<br/>staging-oracle.helloworlddao.com"]
    end

    FOS --> UI
    FOS --> AUTH
    FOS --> API
    DAO --> UI
    DAO --> AUTH
    DAO --> API
    ADMIN --> UI
    ADMIN --> AUTH
    ADMIN --> API

    GOV --> UI

    MKT --> UI

    UI --> AUTH
    AUTH --> API
    API --> AS
    API --> US
    API --> GOVCAN
    API --> TRES

    FOS -.->|cookie SSO| OB
    DAO -.->|cookie SSO| OB
    ADMIN -.->|cookie SSO| OB
    OB -.->|proxies auth| AS

Dependency rules:

  • Suites depend on packages. Packages depend on other packages in the chain api -> auth -> ui.
  • Suites NEVER depend on other suites.
  • Standalone suites (marketing, otter-camp) do not depend on auth or api packages.
  • oracle-bridge proxies authentication requests for cookie-based SSO.

CI/CD Flow

mermaid
graph LR
    subgraph "Package Publishing"
        TAG["Tag Push v0.x.x"] --> PPW["package-publish.yml"]
        PPW --> TEST1["npm test"]
        TEST1 --> BUILD1["npm run build"]
        BUILD1 --> PUB["npm publish<br/>GitHub Packages"]
    end

    subgraph "Suite Deployment"
        PUSH["Push to main"] --> CI["ci.yml"]
        CI --> TSC["tsc --noEmit"]
        TSC --> LINT["eslint"]
        LINT --> TEST2["npm test"]
        TEST2 --> BUILD2["npm run build"]
        BUILD2 --> DEPLOY["deploy-staging.yml"]
        DEPLOY --> DFX["dfx deploy<br/>IC asset canister"]
    end

Cross-Suite Auth Flow

mermaid
sequenceDiagram
    participant User
    participant Suite A (e.g. think-tank)
    participant oracle-bridge
    participant auth-service canister
    participant Suite B (e.g. dao-suite)

    User->>Suite A: Login (email/password or II)
    Suite A->>oracle-bridge: POST /auth/login
    oracle-bridge->>auth-service canister: Authenticate
    auth-service canister-->>oracle-bridge: Session token
    oracle-bridge-->>Suite A: Set-Cookie (domain=.helloworlddao.com, httpOnly, secure, sameSite=lax)
    Note over Suite A: Cookie stored for .helloworlddao.com domain

    User->>Suite B: Navigate to different suite
    Note over Suite B: Cookie sent automatically (same parent domain)
    Suite B->>oracle-bridge: GET /auth/session (cookie included)
    oracle-bridge->>auth-service canister: Validate session
    auth-service canister-->>oracle-bridge: Session valid, user data
    oracle-bridge-->>Suite B: 200 OK + user data
    Suite B->>Suite B: authCookieClient populates localStorage
    Suite B-->>User: Authenticated view

Key CI/CD Details

  • Authentication: OIDC via GITHUB_TOKEN (no PAT needed)
  • Action pinning: All GitHub Actions SHA-pinned for supply-chain security
  • Reusable workflows: Referenced via @main (not SHA) from Hello-World-Co-Op/.github
  • Permissions: Explicit minimal permissions (contents: read, packages: read)
  • Registry: npm.pkg.github.com with scope @hello-world-co-op

RBAC Architecture

Role-Based Access Control (RBAC) is implemented platform-wide via the auth-service canister, with role information propagated through the authentication flow to frontend suites.

Role Storage and Management

Role storage: Roles are stored in the auth-service canister state:

rust
// auth-service canister state
struct State {
    users: HashMap<Principal, UserProfile>,
    user_roles: HashMap<Principal, Vec<Role>>,  // Principal -> ["Admin", "Member"]
    sessions: HashMap<String, UserSession>,
}

Role types:

  • Admin - Full system access (KYC review, member management, system monitoring)
  • Moderator - Content and community management
  • Member - Standard authenticated user (default)

Assignment rules:

  • New users automatically receive Member role on registration
  • Admin and Moderator roles are manually assigned by existing Admins or the canister controller
  • Roles are additive (user can have multiple roles)

Session Enhancement with Roles

When a session is created (login), the auth-service canister includes the user's roles in the UserSession struct:

rust
struct UserSession {
    user_id: Principal,
    email: String,
    roles: Vec<String>,        // ["admin", "member"]
    access_token: String,
    refresh_token: String,
    created_at: u64,
    expires_at: u64,
}

Roles are cached in the session token for performance. Important: Role changes take effect on the user's next login (sessions are not updated retroactively).

Oracle-Bridge Role Propagation

The oracle-bridge service exposes roles via the session endpoint:

Endpoint: GET /api/auth/session

Response:

json
{
  "authenticated": true,
  "user_id": "2vxsx-fae",
  "roles": ["admin", "member"]
}

This endpoint is called by frontend suites on app load to populate the auth state, including roles.

Auth Package RBAC Support

The @hello-world-co-op/auth package (v0.2.0+) provides React components and hooks for role-based UI:

Key exports:

  • useRoles() - Hook providing { roles, isLoading, hasRole, isAdmin }
  • <RoleGuard role="admin"> - Conditional rendering component
  • hasRole(role: string) - Utility function for role checks
  • isAdmin - Boolean convenience property

Example usage:

typescript
import { RoleGuard, useRoles } from '@hello-world-co-op/auth';

function Dashboard() {
  const { isAdmin, hasRole } = useRoles();

  return (
    <div>
      <h1>Dashboard</h1>

      {/* Admin-only section */}
      <RoleGuard role="admin">
        <AdminPanel />
      </RoleGuard>

      {/* Admin or Moderator */}
      <RoleGuard role={["admin", "moderator"]}>
        <ModerationTools />
      </RoleGuard>

      {/* Conditional logic */}
      {isAdmin && <button>System Settings</button>}
    </div>
  );
}

Security note: Frontend role guards are UX conveniences only. Always verify roles server-side in canister methods.

Canister Role Verification

Backend canisters validate roles by calling auth-service::validate_session_with_role:

rust
#[update]
async fn admin_only_method(access_token: String) -> Result<String, String> {
    let auth_service_id = Principal::from_text(AUTH_SERVICE_CANISTER_ID)?;

    let result: Result<(Result<SessionInfo, String>,), _> = call(
        auth_service_id,
        "validate_session_with_role",
        (access_token, "admin".to_string()),
    ).await;

    match result {
        Ok((Ok(session_info),)) => {
            // User is authenticated and has Admin role
            perform_admin_action(session_info.user_id)
        }
        Ok((Err(e),)) => Err(format!("Access denied: {}", e)),
        Err((code, msg)) => Err(format!("Auth service error: {:?} - {}", code, msg)),
    }
}

This pattern centralizes role management and eliminates the need for each canister to maintain its own role state.

RBAC Data Flow

mermaid
sequenceDiagram
    participant User
    participant Suite as Frontend Suite
    participant OB as oracle-bridge
    participant AS as auth-service
    participant Canister as Backend Canister

    Note over User,AS: Authentication + Role Assignment
    User->>Suite: Login
    Suite->>OB: POST /auth/login
    OB->>AS: authenticate(email, password)
    AS->>AS: Validate credentials
    AS->>AS: Load user roles from state
    AS-->>OB: Session + roles
    OB-->>Suite: Set-Cookie + session data
    Suite->>Suite: Store roles in $userRoles atom

    Note over User,Canister: Role-Based UI Rendering
    Suite->>Suite: useRoles() reads $userRoles
    Suite->>Suite: RoleGuard conditionally renders
    Suite-->>User: Role-appropriate UI

    Note over User,Canister: Server-Side Role Verification
    User->>Suite: Admin action (e.g., approve KYC)
    Suite->>Canister: approve_kyc(token, submission_id)
    Canister->>AS: validate_session_with_role(token, "admin")
    AS->>AS: Verify token + check user has Admin role
    alt Has Admin Role
        AS-->>Canister: Ok(SessionInfo)
        Canister->>Canister: Perform admin operation
        Canister-->>Suite: Success
        Suite-->>User: Action complete
    else Missing Role or Invalid Token
        AS-->>Canister: Err("Role not found")
        Canister-->>Suite: Access denied
        Suite-->>User: Error message
    end

Key points:

  1. Roles are assigned in auth-service canister state
  2. Roles are cached in session tokens at login time
  3. oracle-bridge exposes roles via /api/auth/session endpoint
  4. Frontend suites read roles from session and render role-appropriate UI
  5. Backend canisters verify roles via validate_session_with_role inter-canister call
  6. Role changes take effect on next login (sessions are not updated retroactively)

Role-Aware Suite Implementations

SuiteRBAC ImplementationProtected Routes/Features
dao-admin-suiteRoleGuard role="admin" wraps all routes except /login, /unauthorizedKYC review, member management, system monitoring, governance oversight, treasury management, content moderation
think-tank-suiteAdminGuard uses canister-level check (not auth-service RBAC)Admin-specific features (deferred migration to auth-service RBAC)
dao-suiteRoleGuard available for future role-gated features(Planned: proposal moderation, member directory filtering)
governance-suiteRoleGuard available for future role-gated features(Planned: proposal category restrictions, emergency proposals)

Migration notes:

  • think-tank-suite uses a canister-based AdminGuard that checks admin status directly in the dao-admin canister (not auth-service). Migration to auth-service RBAC is deferred for future work.
  • Other suites can adopt RBAC by upgrading to @hello-world-co-op/auth@^0.2.0 and using <RoleGuard> components.

Age Gate and Registration Flow

Status: Implemented (Epic BL-011)

The registration flow implements a COPPA-compliant age gate that verifies user age before any personal information is collected. This is implemented in the marketing-suite (public registration) and enforced server-side in the user-service canister.

For complete compliance details, see the COPPA Compliance documentation (available in the architecture/compliance directory).

Registration Flow with Age Gate

mermaid
flowchart TD
    A[User visits /register] --> B[DOB Input Displayed]
    B --> C{Age Calculation}
    C -->|Under 13| D[Block Screen]
    C -->|13-17| E[Form Revealed - Minor]
    C -->|18+| F[Form Revealed - Adult]
    D --> G[localStorage block flag set - 24h TTL]
    D --> H[Return to Home button only]
    E --> I[Fill PII: Name, Email, Password]
    F --> I
    I --> J[Client encrypts PII with AES-256-GCM]
    J --> K[Submit to user-service canister]
    K --> L{Server validates age}
    L -->|Under 13| M[Reject - COPPA block]
    L -->|13+| N[Create account]
    N --> O{Age >= 18?}
    O -->|Yes| P[is_age_verified_18_plus = true]
    O -->|No| Q[is_age_verified_18_plus = false]
    P --> R[Email verification]
    Q --> R
    R --> S[Mint Registered SBT]

Key Design Decisions

DOB as first field: The Date of Birth input is rendered before any PII fields (name, email, password). PII fields are conditionally rendered only after age is verified as 13+. This ensures COPPA compliance by preventing data collection from children under 13.

Two-phase membership minting:

  • Phase 1 (Registration): mint_membership_registered() creates an SBT with MembershipStatus::Registered. No expiration. Cannot vote.
  • Phase 2 (Upgrade): upgrade_to_active() transitions Registered to Active when user is 18+ and has paid dues. Sets December 31 expiry.

DOB as encrypted PII: DOB is encrypted client-side with AES-256-GCM (same master recovery key as name/email) and stored in the user-service canister as dob_encrypted. It is NOT stored on-chain in the membership SBT. A derived boolean is_age_verified_18_plus is cached for access control decisions.

Anti-circumvention: Client-side localStorage block flag (__hw_age_block, 24-hour TTL) deters casual re-attempts. Server-side validation in validate_age_from_dob() is the authoritative control and cannot be bypassed by client manipulation.

Age-Based Access Matrix

FeatureUnder 1313-17 (Registered)18+ (Registered)18+ (Active)
RegistrationBlockedAllowedAllowedN/A
Platform accessNoneLimitedLimitedFull
Otter Camp gameNoYesYesYes
Governance votingNoNoNoYes
Proposal creationNoNoNoYes
Upgrade to ActiveN/ABlocked (age < 18)Pay duesN/A
  • COPPA Compliance -- Full COPPA compliance document with FTC rule mapping (see architecture/compliance directory)
  • Epic BL-011 -- Epic plan for registration + COPPA (see bmad-artifacts/planning-artifacts directory)

Cross-Suite Navigation

Cross-suite navigation allows users to seamlessly switch between different suite applications while maintaining their authentication state. This is implemented via the SuiteSwitcher component in @hello-world-co-op/ui@0.2.1.

SuiteSwitcher Component Overview

The SuiteSwitcher is a shared component that provides a consistent navigation experience across all authenticated suites. It displays a dropdown menu with links to other suite applications.

Location: @hello-world-co-op/ui package Component: SuiteSwitcherVersion: Available since v0.2.1

Architecture

The cross-suite navigation system follows a shared component, per-suite integration pattern:

  1. Shared Component: The SuiteSwitcher UI component is defined once in the @hello-world-co-op/ui package
  2. Environment Configuration: Each suite defines its own environment variables pointing to other suite URLs
  3. Props-Based Integration: Suites pass their specific configuration via props to the shared component
  4. Role-Based Visibility: The admin suite link is only shown to users with the Admin role

Role-Based Visibility

The SuiteSwitcher component integrates with the platform RBAC system to conditionally display suite links based on user roles:

  • Admin Suite: Only visible to users with the Admin role (checked via useRoles() hook)
  • Other Suites: Visible to all authenticated users

Example implementation:

tsx
import { SuiteSwitcher } from '@hello-world-co-op/ui';
import { useRoles } from '@hello-world-co-op/auth';

function Layout() {
  const { isAdmin } = useRoles();

  return (
    <SuiteSwitcher
      currentSuite="think-tank"
      suiteUrls={{
        portal: import.meta.env.VITE_DAO_FRONTEND_URL,
        founderyOs: import.meta.env.VITE_THINK_TANK_URL,
        governance: import.meta.env.VITE_GOVERNANCE_SUITE_URL,
        otterCamp: import.meta.env.VITE_OTTER_CAMP_URL,
        ...(isAdmin && { admin: import.meta.env.VITE_ADMIN_SUITE_URL }),
      }}
    />
  );
}

Environment Variable Pattern

Each suite that implements cross-suite navigation defines the following environment variables:

VariableDescription
VITE_DAO_FRONTEND_URLURL for the DAO portal (main member dashboard)
VITE_THINK_TANK_URLURL for Think Tank productivity suite
VITE_GOVERNANCE_SUITE_URLURL for governance proposals and voting
VITE_OTTER_CAMP_URLURL for Otter Camp game suite
VITE_ADMIN_SUITE_URLURL for admin dashboard (admin users only)

Example values (staging):

bash
VITE_DAO_FRONTEND_URL=https://staging-portal.helloworlddao.com
VITE_THINK_TANK_URL=https://staging-think-tank.helloworlddao.com
VITE_GOVERNANCE_SUITE_URL=https://staging-governance.helloworlddao.com
VITE_OTTER_CAMP_URL=https://staging-ottercamp.helloworlddao.com
VITE_ADMIN_SUITE_URL=https://staging-admin.helloworlddao.com

Integration Pattern

Suites integrate the SuiteSwitcher component by:

  1. Installing @hello-world-co-op/ui@^0.2.1 (or later)
  2. Defining suite URL environment variables
  3. Importing and rendering the component with appropriate props

Code example:

tsx
import { SuiteSwitcher } from '@hello-world-co-op/ui';

function AppLayout() {
  return (
    <div className="app-layout">
      <header>
        <SuiteSwitcher
          currentSuite="think-tank"  // 'portal' | 'think-tank' | 'governance' | 'otter-camp' | 'admin'
          suiteUrls={{
            portal: import.meta.env.VITE_DAO_FRONTEND_URL,
            founderyOs: import.meta.env.VITE_THINK_TANK_URL,
            governance: import.meta.env.VITE_GOVERNANCE_SUITE_URL,
            otterCamp: import.meta.env.VITE_OTTER_CAMP_URL,
            admin: import.meta.env.VITE_ADMIN_SUITE_URL,
          }}
        />
      </header>
      {/* ... */}
    </div>
  );
}

Suite Implementation Status

SuiteHas SuiteSwitcherReason
think-tank-suiteYesAuthenticated productivity app
dao-suiteYesAuthenticated member dashboard
governance-suiteYesAuthenticated governance app
dao-admin-suiteYesAdmin-only dashboard with role-based access
marketing-suiteNoPublic-facing marketing site (no authentication)
otter-camp-suiteNoStandalone game with independent auth pattern

Rationale for exclusions:

  • marketing-suite: Public site with no user authentication — cross-suite navigation is not applicable
  • otter-camp-suite: Standalone game with passive auth (read-only localStorage). Game does not actively manage sessions or make authenticated canister calls, so cross-suite navigation would create confusion

Cross-Suite Authentication

Authentication flows through the shared auth-service canister via oracle-bridge. The oracle-bridge acts as a proxy that sets httpOnly cookies on the .helloworlddao.com domain, enabling single sign-on across all suites.

PropertyValuePurpose
Domain.helloworlddao.comShared across all subdomains
SameSitelaxAllows cross-subdomain navigation
SecuretrueHTTPS only
HttpOnlytrueNot accessible via JavaScript

Critical: The leading dot in .helloworlddao.com is required. Without it, cookies are scoped to a single subdomain and SSO breaks.

Auth Endpoints (via oracle-bridge)

EndpointMethodPurpose
/auth/loginPOSTAuthenticate and set session cookie
/auth/sessionGETValidate session, return user data
/auth/logoutPOSTClear session cookie (requires CSRF token)
/healthGETHealth check

Staging URL: https://staging-oracle.helloworlddao.comLocal URL: http://localhost:3000

localStorage Bridge

For suites using the Auth Bridge pattern, cookie-based session data is synchronized to localStorage via authCookieClient. This is needed because some legacy components read auth state from localStorage rather than cookies.

Flow: Cookie (set by oracle-bridge) -> authCookieClient reads session -> populates localStorage.user_data

Migration Patterns

When migrating an existing suite to consume shared packages, two patterns are used:

Re-export Pattern (API)

Suite wraps shared package exports in a local module for gradual migration:

typescript
// src/services/apiHelpers.ts
export { createActor } from '@hello-world-co-op/api';
export type { ApiResponse, ActorOptions } from '@hello-world-co-op/api';
export { isSuccess, isError, ERROR_CODES } from '@hello-world-co-op/api';

Existing code continues importing from ../services/apiHelpers -- no import path changes needed.

Bridge Pattern (Auth)

Suite creates a context bridge that maps internal auth state to the shared interface:

typescript
// src/components/auth/AuthProviderBridge.tsx
import type { AuthContextValue } from '@hello-world-co-op/auth';
// ... bridges nanostores auth -> AuthContextValue

Direct Consumption (UI)

Components imported directly from packages:

typescript
import { ErrorBoundary } from '@hello-world-co-op/ui';
import { cn } from '@hello-world-co-op/ui';

Architecture Decision Records

Standalone vs Shared Package Decision Matrix

When creating a new suite, use this matrix to decide whether it should consume shared packages or remain standalone:

FactorUse Shared PackagesStay Standalone
User authenticationSuite requires login, sessions, protected routesSuite is public-facing, no user-specific content
IC canister interactionSuite calls multiple canisters (governance, treasury, etc.)Suite has no or minimal canister interaction
UI consistencySuite must match the authenticated app experienceSuite has a distinct visual identity (game, marketing)
Update frequencySuite benefits from coordinated auth/API updatesSuite has an independent release cycle
Bundle size sensitivityBundle size is not a primary concernEvery KB matters (games, public landing pages)
Rendering strategyClient-side SPA with runtime authSSR/SSG pre-rendering for SEO

Decision rule: If a suite requires user authentication or makes IC canister calls on behalf of users, use shared packages (Auth Bridge or Auth Direct). If the suite is public-facing or has its own rendering engine, stay standalone.

Validated examples:

  • Shared (Auth Bridge): think-tank-suite, dao-suite, dao-admin-suite — complex authenticated apps with many canister calls
  • Shared (Auth Direct): governance-suite — reads auth state via cookies, uses @hello-world-co-op/ui only
  • Standalone with UI: marketing-suite — public site with SEO pre-rendering, uses @hello-world-co-op/ui for visual consistency but no auth
  • Standalone without packages: otter-camp-suite — Phaser.js game engine, no shared package dependencies

Marketing Suite: Standalone Architecture Decision

The marketing-suite intentionally deviates from the shared package pattern. This is a deliberate architectural decision, not an oversight.

Rationale:

  • Leanness: Marketing site serves static/SEO content. Adding auth and api packages would inflate the bundle with unused dependencies.
  • Different lifecycle: Marketing content changes independently of app features. Decoupling from shared package versions prevents unnecessary coordination.
  • Minimal canister interaction: The marketing site makes zero IC canister calls. The @hello-world-co-op/api package provides no value here.
  • Pre-rendering compatibility: SSG (vite-ssg) with ReactDOMServer.renderToString() works best with minimal dependencies. Auth context and IC agent code add hydration complexity.

What it does use: @hello-world-co-op/ui for shared Tailwind config and base components (Button, Card, etc.) to maintain visual consistency with the authenticated suites.

Game Suite Extraction Pattern

The otter-camp-suite extraction from the frontend monolith established patterns for game-specific suites:

Bundle optimization: Phaser.js (~340KB gzipped) is extracted to a standalone lazy-loaded chunk. The game page is lazy-imported in App.tsx, keeping the initial bundle at ~58KB. Phaser and game code load only when the user navigates to /otter-camp.

React-Phaser bridging: Game suites use nanostores for state management instead of React Context. This enables:

  • Game engine (Phaser) to read/write state without React re-renders
  • React UI overlays (HUD, menus) to reactively update from game state
  • Clean separation between game loop and React lifecycle

ESLint configuration: Game code requires relaxed linting rules compared to standard React:

  • no-param-reassign: off — Phaser mutates game objects in place
  • class-methods-use-this: off — Phaser lifecycle methods (create, update) don't always use this
  • no-bitwise: off — Common in game math (color manipulation, flags)

Auth integration: Game suites read user_data from localStorage passively (read-only). Auth state is set by auth-integrated suites (dao-suite, think-tank). The game never performs auth operations itself — it only checks if a user is logged in to unlock member-only features.

WebSocket integration: Multiplayer presence uses WebSocket connections managed by the game engine, not React. The connection lifecycle is tied to the Phaser scene, not React component mount/unmount.

IC Canister Communication Patterns

Anti-Pattern: fetch() with no-cors for Canister Queries

Never use fetch() with mode: 'no-cors' for Internet Computer canister queries. This is a common mistake when developers encounter CORS errors and attempt to work around them.

Why this fails:

  • IC boundary nodes require proper CORS headers for canister queries
  • no-cors mode makes responses opaque (unreadable) — you cannot access the response body or status
  • The IC HTTP Gateway expects Candid-encoded requests, not plain HTTP
  • You will get silent failures or empty responses that are impossible to debug

Example of incorrect pattern:

typescript
// ❌ ANTI-PATTERN - Do not use
const response = await fetch(`https://ic0.app/api/v2/canister/${canisterId}/query`, {
  mode: 'no-cors',  // Makes response opaque and unreadable
});
// response.json() will throw - response body is inaccessible

Correct Pattern: HttpAgent for Canister Queries

Use @dfinity/agent's HttpAgent and Actor for all canister communication:

Anonymous queries (for public query methods):

typescript
import { HttpAgent, Actor } from '@dfinity/agent';
import { IDL } from '@dfinity/candid';

const IC_HOST = import.meta.env.VITE_IC_HOST || 'https://ic0.app';
const CANISTER_ID = import.meta.env.VITE_CANISTER_ID;

// Define minimal IDL for the methods you need
const idlFactory = ({ IDL }) => {
  return IDL.Service({
    icrc1_balance_of: IDL.Func(
      [IDL.Record({ owner: IDL.Principal, subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)) })],
      [IDL.Nat],
      ['query'],
    ),
  });
};

// Anonymous agent - safe for public queries
const agent = HttpAgent.createSync({ host: IC_HOST });
const actor = Actor.createActor(idlFactory, {
  agent,
  canisterId: CANISTER_ID,
});

// Query the canister
const balance = await actor.icrc1_balance_of({ owner, subaccount: [] });

Authenticated update calls (require session token):

typescript
import { HttpAgent, Actor } from '@dfinity/agent';

// Create an authenticated agent with identity
const agent = HttpAgent.createSync({
  host: IC_HOST,
  identity: yourIdentity, // From @dfinity/identity
});

const actor = Actor.createActor(idlFactory, { agent, canisterId });
const result = await actor.update_method(args);

When to Use fetch() vs HttpAgent

ScenarioToolExample
IC canister queryHttpAgent + ActorFetching token balance, blog posts, membership status
IC canister updateHttpAgent + ActorCreating proposals, minting NFTs, transferring tokens
oracle-bridge REST endpointfetch() with credentials: 'include'/api/auth/login, /api/auth/session, /api/auth/logout
External HTTP APIfetch()Third-party APIs, webhooks, external services

Key rule: If you're calling an IC canister method defined in a .did file, use HttpAgent. If you're calling a REST endpoint (oracle-bridge, external API), use fetch().

Real-World Examples

Blog post listing (anonymous query):

typescript
// From: marketing-suite/src/services/blogCanister.ts
const idlFactory = ({ IDL }) => {
  return IDL.Service({
    list_posts: IDL.Func(
      [IDL.Record({ page: IDL.Nat32, page_size: IDL.Nat32 })],
      [IDL.Variant({ Ok: PaginatedPostResultType, Err: BlogErrorVariant })],
      ['query'],
    ),
  });
};

const agent = HttpAgent.createSync({ host: 'https://ic0.app' });
const actor = Actor.createActor(idlFactory, {
  agent,
  canisterId: import.meta.env.VITE_BLOG_CANISTER_ID,
});

const result = await actor.list_posts({ page: 1, page_size: 50 });

Token balance check (anonymous query):

typescript
// From: dao-suite/src/services/tokenService.ts
const icrc1BalanceOfIdl = IDL.Service({
  icrc1_balance_of: IDL.Func(
    [IDL.Record({ owner: IDL.Principal, subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)) })],
    [IDL.Nat],
    ['query'],
  ),
});

const agent = HttpAgent.createSync({ host: IC_HOST });
const actor = Actor.createActor(() => icrc1BalanceOfIdl, {
  agent,
  canisterId: DOM_TOKEN_CANISTER_ID,
});

const balance = await actor.icrc1_balance_of({ owner, subaccount: [] });

Authentication (oracle-bridge REST endpoint):

typescript
// From: @hello-world-co-op/auth package
const response = await fetch(`${oracleBridgeUrl}/api/auth/login`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',  // Required for cookie-based auth
  body: JSON.stringify({ email, password }),
});

Testing and Development

For local development with PocketIC:

typescript
const agent = HttpAgent.createSync({
  host: 'http://127.0.0.1:4943', // Local PocketIC instance
});

// Must fetch root key for local replicas
if (import.meta.env.VITE_NETWORK === 'local') {
  await agent.fetchRootKey();
}

For production (IC mainnet):

typescript
const agent = HttpAgent.createSync({
  host: 'https://ic0.app', // IC mainnet boundary node
});
// Never call fetchRootKey() in production - uses hardcoded root key

Hello World Co-Op DAO