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
- Package Architecture
- Suite Architecture
- Suite Patterns
- Dependency Diagram
- CI/CD Pipeline
- Cross-Suite Authentication
- Migration Patterns
- Architecture Decision Records
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
.githuborg 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 documentationAll Deployed Suites
| Suite | Canister ID | Staging Domain | Production Domain | Tests | Bundle Size | Pattern |
|---|---|---|---|---|---|---|
| think-tank-suite | wnfjk-biaaa-aaaao-a6dhq-cai | staging-think-tank.helloworlddao.com | think-tank.helloworlddao.com | 4,642 | -- | Auth Bridge |
| governance-suite | wkep6-mqaaa-aaaao-a6dha-cai | staging-governance.helloworlddao.com | governance.helloworlddao.com | 936 | -- | Auth Direct |
| marketing-suite | d5fe6-hqaaa-aaaao-a6t5q-cai | staging.helloworlddao.com | www.helloworlddao.com | 83 | 358KB | Standalone |
| otter-camp-suite | dzt3i-sqaaa-aaaao-a6uaa-cai | staging-ottercamp.helloworlddao.com | ottercamp.helloworlddao.com | 2,122 | 58KB initial | Standalone |
| dao-suite | d6s54-7iaaa-aaaao-a6uaq-cai | staging-portal.helloworlddao.com | portal.helloworlddao.com | 806 | 348KB | Auth Bridge |
| dao-admin-suite | dxrwa-jaaaa-aaaao-a6uba-cai | staging-admin.helloworlddao.com | admin.helloworlddao.com | 115 | 93KB | Auth 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)AuthProviderBridgewraps the app root, providing@hello-world-co-op/auth'sAuthContextValuebacked by nanostoresuseAuth()hook reads from nanostores and returns the shared interfaceapiHelpers.tsre-exportscreateActor,ApiResponse, etc. from@hello-world-co-op/apigetAuthHeader()is cookie-auth aware (checksVITE_USE_COOKIE_AUTH)- Cookie SSO populates localStorage via
authCookieClientbridge 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
AdminGuardRBAC component and admin-specificvalidateReturnUrl
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 canisterErrorBoundaryfrom@hello-world-co-op/uiwraps the app- Auth state managed via nanostores (no AuthProvider bridge)
- Build-time auth bypass prevention:
vite.config.tsrejectsVITE_DEV_AUTH_BYPASSin 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_datafrom localStorage passively (read-only, set by auth suites). No IC canister calls from game code.
When to Use Each Pattern
| Scenario | Pattern | Example |
|---|---|---|
| Suite needs user login, session management, protected routes | Auth Bridge | dao-suite |
| Suite only needs to read auth state (cookie) | Auth Direct | governance-suite |
| Suite is public-facing, no user-specific content | Standalone | marketing-suite |
| Suite has its own rendering engine (game, canvas) | Standalone | otter-camp-suite |
| Suite needs admin-level RBAC | Auth Bridge + AdminGuard | dao-admin-suite |
Dependency Diagram
Suite and Package Dependencies
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| ASDependency 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
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"]
endCross-Suite Auth Flow
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 viewKey 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) fromHello-World-Co-Op/.github - Permissions: Explicit minimal permissions (
contents: read,packages: read) - Registry:
npm.pkg.github.comwith 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:
// 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 managementMember- Standard authenticated user (default)
Assignment rules:
- New users automatically receive
Memberrole on registration AdminandModeratorroles 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:
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:
{
"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 componenthasRole(role: string)- Utility function for role checksisAdmin- Boolean convenience property
Example usage:
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:
#[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
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
endKey points:
- Roles are assigned in
auth-servicecanister state - Roles are cached in session tokens at login time
oracle-bridgeexposes roles via/api/auth/sessionendpoint- Frontend suites read roles from session and render role-appropriate UI
- Backend canisters verify roles via
validate_session_with_roleinter-canister call - Role changes take effect on next login (sessions are not updated retroactively)
Role-Aware Suite Implementations
| Suite | RBAC Implementation | Protected Routes/Features |
|---|---|---|
| dao-admin-suite | RoleGuard role="admin" wraps all routes except /login, /unauthorized | KYC review, member management, system monitoring, governance oversight, treasury management, content moderation |
| think-tank-suite | AdminGuard uses canister-level check (not auth-service RBAC) | Admin-specific features (deferred migration to auth-service RBAC) |
| dao-suite | RoleGuard available for future role-gated features | (Planned: proposal moderation, member directory filtering) |
| governance-suite | RoleGuard available for future role-gated features | (Planned: proposal category restrictions, emergency proposals) |
Migration notes:
think-tank-suiteuses a canister-basedAdminGuardthat checks admin status directly in thedao-admincanister (notauth-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.0and 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
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 withMembershipStatus::Registered. No expiration. Cannot vote. - Phase 2 (Upgrade):
upgrade_to_active()transitionsRegisteredtoActivewhen 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
| Feature | Under 13 | 13-17 (Registered) | 18+ (Registered) | 18+ (Active) |
|---|---|---|---|---|
| Registration | Blocked | Allowed | Allowed | N/A |
| Platform access | None | Limited | Limited | Full |
| Otter Camp game | No | Yes | Yes | Yes |
| Governance voting | No | No | No | Yes |
| Proposal creation | No | No | No | Yes |
| Upgrade to Active | N/A | Blocked (age < 18) | Pay dues | N/A |
Related Documentation
- 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:
- Shared Component: The
SuiteSwitcherUI component is defined once in the@hello-world-co-op/uipackage - Environment Configuration: Each suite defines its own environment variables pointing to other suite URLs
- Props-Based Integration: Suites pass their specific configuration via props to the shared component
- 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:
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:
| Variable | Description |
|---|---|
VITE_DAO_FRONTEND_URL | URL for the DAO portal (main member dashboard) |
VITE_THINK_TANK_URL | URL for Think Tank productivity suite |
VITE_GOVERNANCE_SUITE_URL | URL for governance proposals and voting |
VITE_OTTER_CAMP_URL | URL for Otter Camp game suite |
VITE_ADMIN_SUITE_URL | URL for admin dashboard (admin users only) |
Example values (staging):
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.comIntegration Pattern
Suites integrate the SuiteSwitcher component by:
- Installing
@hello-world-co-op/ui@^0.2.1(or later) - Defining suite URL environment variables
- Importing and rendering the component with appropriate props
Code example:
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
| Suite | Has SuiteSwitcher | Reason |
|---|---|---|
| think-tank-suite | Yes | Authenticated productivity app |
| dao-suite | Yes | Authenticated member dashboard |
| governance-suite | Yes | Authenticated governance app |
| dao-admin-suite | Yes | Admin-only dashboard with role-based access |
| marketing-suite | No | Public-facing marketing site (no authentication) |
| otter-camp-suite | No | Standalone 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.
Cookie Configuration
| Property | Value | Purpose |
|---|---|---|
| Domain | .helloworlddao.com | Shared across all subdomains |
| SameSite | lax | Allows cross-subdomain navigation |
| Secure | true | HTTPS only |
| HttpOnly | true | Not 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)
| Endpoint | Method | Purpose |
|---|---|---|
/auth/login | POST | Authenticate and set session cookie |
/auth/session | GET | Validate session, return user data |
/auth/logout | POST | Clear session cookie (requires CSRF token) |
/health | GET | Health 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:
// 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:
// src/components/auth/AuthProviderBridge.tsx
import type { AuthContextValue } from '@hello-world-co-op/auth';
// ... bridges nanostores auth -> AuthContextValueDirect Consumption (UI)
Components imported directly from packages:
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:
| Factor | Use Shared Packages | Stay Standalone |
|---|---|---|
| User authentication | Suite requires login, sessions, protected routes | Suite is public-facing, no user-specific content |
| IC canister interaction | Suite calls multiple canisters (governance, treasury, etc.) | Suite has no or minimal canister interaction |
| UI consistency | Suite must match the authenticated app experience | Suite has a distinct visual identity (game, marketing) |
| Update frequency | Suite benefits from coordinated auth/API updates | Suite has an independent release cycle |
| Bundle size sensitivity | Bundle size is not a primary concern | Every KB matters (games, public landing pages) |
| Rendering strategy | Client-side SPA with runtime auth | SSR/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/uionly - Standalone with UI: marketing-suite — public site with SEO pre-rendering, uses
@hello-world-co-op/uifor 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/apipackage 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 placeclass-methods-use-this: off— Phaser lifecycle methods (create,update) don't always usethisno-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-corsmode 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:
// ❌ 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 inaccessibleCorrect Pattern: HttpAgent for Canister Queries
Use @dfinity/agent's HttpAgent and Actor for all canister communication:
Anonymous queries (for public query methods):
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):
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
| Scenario | Tool | Example |
|---|---|---|
| IC canister query | HttpAgent + Actor | Fetching token balance, blog posts, membership status |
| IC canister update | HttpAgent + Actor | Creating proposals, minting NFTs, transferring tokens |
| oracle-bridge REST endpoint | fetch() with credentials: 'include' | /api/auth/login, /api/auth/session, /api/auth/logout |
| External HTTP API | fetch() | 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):
// 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):
// 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):
// 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:
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):
const agent = HttpAgent.createSync({
host: 'https://ic0.app', // IC mainnet boundary node
});
// Never call fetchRootKey() in production - uses hardcoded root keyRelated Documentation
- Repository Map -- Which repo contains what
- Local Setup Guide -- Getting started locally
- Suite Creation Guide -- Creating new suites from template
- Troubleshooting Guide -- Common issues and fixes
- Cross-Suite Auth Debugging -- Diagnosing SSO and auth issues
- Rollback Procedures -- Suite-specific rollback steps
- Local Dev Workflow -- npm link cross-package development