Oracle-Bridge Canister Call Patterns
Reference for developers adding new oracle-bridge → IC canister interactions. Source: AI-R90 from BL-019 Retrospective (anonymous identity blocker).
Query vs Update Methods
IC canister methods come in two types:
| Type | Candid annotation | Identity required | Example |
|---|---|---|---|
| Query | ['query'] | No (anonymous OK) | validate_session, get_user_roles |
| Update | [] (no annotation) | Yes (controller principal) | assign_role, revoke_role, logout_all |
Query methods are read-only, fast (~200ms), and can be called with anonymous identity (2vxsx-fae). They do not check caller() against the canister's controller list.
Update methods modify canister state, go through consensus (~2s), and typically check caller() against an internal controller or admin list. Using anonymous identity returns "Unauthorized".
Identity Patterns
Anonymous (for queries)
When only calling query methods, no identity configuration is needed:
const agent = HttpAgent.createSync({ host });
const actor = Actor.createActor(idlFactory, { agent, canisterId });
// actor.validate_session(token, null) // works — query methodAuthenticated (for updates)
When calling controller-only update methods, use Ed25519KeyIdentity:
import { Ed25519KeyIdentity } from '@dfinity/identity';
// PRIV_KEY_B64 is a base64-encoded key. It may be:
// - 32 bytes: raw Ed25519 seed
// - 64 bytes: NaCl extended key (seed + public key concatenated)
// Ed25519KeyIdentity.fromSecretKey() requires exactly the 32-byte seed.
const rawKey = Buffer.from(cfg.privKeyB64, 'base64');
const seed = rawKey.length === 64 ? rawKey.subarray(0, 32) : rawKey;
const identity = Ed25519KeyIdentity.fromSecretKey(new Uint8Array(seed));
const agent = HttpAgent.createSync({ host, identity });
const actor = Actor.createActor(idlFactory, { agent, canisterId });
// actor.assign_role(userId, { Admin: null }) // works — signed by controllerCurrent Implementation
createAuthServiceActor() in oracle-bridge/src/ic/auth-service-idl.ts handles both patterns automatically:
- If
PRIV_KEY_B64is set → creates authenticated identity (required for staging/production) - If not set → falls back to anonymous with a warning (local dev only)
Adding a New Canister Call
When adding a new oracle-bridge route that calls a canister method:
Check the Candid annotation in the
.didfile or IDL factory:['query']→ anonymous OK[](empty) → needs authenticated identity
Use
createAuthServiceActor()(or equivalent helper) which already handles identity setup. Do not create a newHttpAgentmanually.Verify on staging — unit tests mock the agent entirely, so identity mismatches are invisible until staging. Always test update calls against the real canister.
If the canister has an internal controller list (like auth-service's
s.controllers), ensure the oracle-bridge principal is registered. For auth-service, this happens viaset_user_service()which syncs the controller list.
Canister Method Reference (auth-service)
| Method | Type | Identity | Purpose |
|---|---|---|---|
validate_session | query | anonymous | Session validation |
get_user_roles | query | anonymous | Read user roles |
login_email_password | update | anonymous* | Login (no controller check) |
refresh_tokens | update | anonymous* | Token refresh (no controller check) |
logout | update | anonymous* | Single session logout |
assign_role | update | controller | Role assignment (admin only) |
revoke_role | update | controller | Role revocation (admin only) |
logout_all | update | controller | Invalidate all sessions |
update_session_principal | update | controller | Link II principal to session |
*These update methods authenticate via the session token in arguments, not the caller principal.
Common Gotchas
64-byte NaCl key:
PRIV_KEY_B64on the VPS is a 64-byte concatenated keypair (private seed + public key).Ed25519KeyIdentity.fromSecretKey()wants only the first 32 bytes. Always userawKey.subarray(0, 32).Controller bootstrap: Even with the correct identity, the oracle-bridge principal must be in the canister's internal controller list. For auth-service, call
set_user_service(user_service_canister_id)from the oracle-bridge identity to trigger the controller sync.Unit tests hide identity issues: Tests mock
@dfinity/agententirely, so anonymous vs authenticated identity is never tested. Only staging smoke tests catch this.
UUID vs IC Principal in Session-Based Canister Calls
Source: AI-R101 from BL-021 Retrospective
validate_access_token returns a user_id in UUID format (e.g., 904599ed-12b6-4314-b0ba-677066afd2ce). This is not an IC Principal.
The membership canister uses IC Principals as member identifiers. Calling Principal::from_text(uuid) will fail — UUIDs are not valid IC Principal text encoding.
Pattern for mutation methods that need IC principal identity
When an oracle-bridge route needs to call a canister update method on behalf of a specific member:
- Use
req.session.ic_principal— populated by therequireSessionAuthmiddleware from auth-service session data. This field was added in BL-027 (update_session_principalflow). - Call controller-only admin methods on the target canister (e.g.,
update_profile_admin(ic_principal, request)) using oracle-bridge's authenticated Ed25519 identity. - Do not use
_with_sessioncanister methods for mutations that require IC principal identification — the UUID/Principal mismatch makes them non-functional.
Pattern for query methods
For query methods (get_directory, get_member_profile), anonymous identity is fine — no caller identity is checked on read-only operations.
Summary table
| Operation type | Identity needed | Use |
|---|---|---|
| Query (read) | None | Anonymous HttpAgent |
| Update on behalf of user | IC Principal | req.session.ic_principal + controller Ed25519 identity |
| Admin update (no user context) | Controller only | Ed25519 identity from PRIV_KEY_B64 |
Reference: BL-021 retrospective, BL-022.4 user-service admin method pattern.
Route File Architecture (AI-R102)
Source: AI-R102 from BL-020 Retrospective
As of 2026-02-18, oracle-bridge has 8 route files: auth, blog-analytics, blog-canister, blog-webhook, discussions, image-upload, members, notifications, roles.
Decision: Continue with flat route file structure. The current count is manageable. Re-evaluate when route count exceeds 12 or when shared business logic between routes creates duplication.
Each route file is self-contained with its own validation, canister calls, and response formatting. No shared service layer is needed at current scale. If duplication emerges (e.g., multiple routes calling the same canister method with the same error handling), extract a shared helper module at that point rather than speculatively.
Controller Audit Before Canister Deploy (AI-R112)
Source: AI-R112 from BL-022 Retrospective
Before deploying any canister WASM (build or upgrade), verify that the github-ci identity is registered as a controller. Attempting to deploy without controller access fails at the deploy step — after the WASM has already been built — wasting build time and causing confusing errors.
Audit command:
DFX_WARNING=-mainnet_plaintext_identity \
dfx canister --network ic status <canister-id> --identity github-ciLook for github-ci's principal in the Controllers: list in the output.
If not a controller: Initiate a controller transfer from an identity that IS a controller (typically the deploying developer's local identity) BEFORE running any build or deploy step:
# Add github-ci as controller (run with an identity that is already a controller)
DFX_WARNING=-mainnet_plaintext_identity \
dfx canister --network ic update-settings <canister-id> \
--add-controller <github-ci-principal>The github-ci principal can be retrieved with:
dfx identity --identity github-ci get-principalRule: Controller verification is a pre-flight check, not a post-failure recovery step. Add it to your deploy runbook before WASM build commands.
getUserServiceActor() vs getControllerUserServiceActor() (AI-R216)
Source: AI-R213/AI-R216 from Bugfix Sprint 1 Retrospective
oracle-bridge has TWO actor factory functions for calling the user-service canister:
| Function | Identity | Use for |
|---|---|---|
getUserServiceActor() | Anonymous | Query methods only: get_display_name, get_user_by_ii_principal, is_ready_for_payment, get_payment_history |
getControllerUserServiceActor() | Ed25519 from PRIV_KEY_B64 | Controller-gated update methods: create_user_ii, update_user_ii_profile, approve_parental_consent, send_parental_consent_email, get_password_hash_by_email_hash, confirm_payment |
How to tell which to use
Check the Candid IDL in src/ic/user-service-idl.ts:
- Method has
['query']annotation →getUserServiceActor()is safe - Method has
[](empty array) annotation → must usegetControllerUserServiceActor()
Bug class: Bugfix Sprint 1
Three controller-gated methods (create_user_ii, update_user_ii_profile, approve_parental_consent) were using getUserServiceActor() (anonymous identity). This worked in local dev (PocketIC has no controller enforcement by default) but silently fails in staging/production — the canister rejects the call with "Not a controller" error, but the oracle-bridge error handling made the failure ambiguous.
Canister Method Reference (user-service)
| Method | Type | Identity | Purpose |
|---|---|---|---|
get_display_name | query | anonymous | Display name lookup |
get_user_by_ii_principal | query | anonymous | II principal → user_id lookup |
is_ready_for_payment | query | anonymous | Payment readiness check |
get_payment_history | query | anonymous | Payment history |
create_user_ii | update | controller | Create new II-first user |
update_user_ii_profile | update | controller | Set email/DOB/display name for II user |
approve_parental_consent | update | controller | Approve COPPA parental consent |
send_parental_consent_email | update | controller | Trigger consent email via HTTP outcall |
get_password_hash_by_email_hash | update | controller | Retrieve password hash for off-chain verification |
confirm_payment | update | controller | Record confirmed payment |
TypeScript Actor Interface
Both query and controller methods are on the UserServicePasswordActor interface in src/ic/user-service-idl.ts. The interface name is misleading (historical — it was originally only for password hash retrieval) but now covers all controller-gated user-service methods.
Created 2026-02-17 — AI-R90 from BL-019 Epic RetrospectiveUpdated 2026-02-18 — AI-R101 (UUID/Principal), AI-R102 (route architecture) from BL-020/BL-021 RetrospectivesUpdated 2026-02-18 — AI-R112 (controller audit pre-deploy) from BL-022 RetrospectiveUpdated 2026-03-08 — AI-R216 (getUserServiceActor vs getControllerUserServiceActor) from Bugfix Sprint 1 Retrospective