Version: 1.0.0-draft Date: 2026-03-05 Status: Design specification
The Joining Service is a per-hApp REST API that brokers the onboarding flow for users of Holochain apps running in the Holo Web Conductor (HWC) browser extension. It centralizes the configuration that HWC clients need to participate in a Holochain network: linker URLs, optional membrane proofs, hApp bundle locations, and identity verification flows.
Each hApp developer runs their own joining service (or uses a hosted one). The HWC client library auto-discovers it via .well-known/holo-joining on the app domain.
First-time join:
1. User loads web page
2. Extension auto-detected (or download prompted)
3. Client discovers joining service via .well-known
4. GET /v1/info → R/O gateway URLs (optional browse-before-join)
5. Extension generates agent key
6. POST /v1/join → session + challenges (if any)
7. User completes verification challenges (if any)
8. GET /v1/join/{session}/provision → linker URLs, membrane proof, hApp bundle URL
9. Client installs hApp with provision data
10. Standard hApp UI operates
Reconnect (linker URLs expired or infrastructure changed):
11. POST /v1/reconnect { agent_key, timestamp, signature }
→ updated linker URLs, gateway URLs
12. Client reconnects to new linker URLs
The API is versioned via URL path prefix:
https://app.example.com/.well-known/holo-joining (discovery)
https://joining.example.com/v1/info (API endpoints)
https://joining.example.com/v1/join (API endpoints)
The discovery endpoint returns the versioned base URL. Clients resolve it from .well-known and never hardcode the API path.
All responses include the header:
X-Joining-Service-Version: 1.0
Served from the app domain (the domain where the hApp UI is hosted). Returns a pointer to the joining service.
Response (200 OK):
{
"joining_service_url": "https://joining.example.com/v1",
"happ_id": "mewsfeed",
"version": "1.0"
}| Field | Type | Required | Description |
|---|---|---|---|
joining_service_url |
string (URL) | yes | Base URL for the joining service API (includes version prefix) |
happ_id |
string | yes | Identifier for this hApp (used for logging/routing, not cryptographic) |
version |
string | yes | Version of the well-known format ("1.0") |
Headers:
Content-Type: application/jsonAccess-Control-Allow-Origin: *Cache-Control: public, max-age=3600
If the file does not exist, the client falls back to manual configuration (developer passes linkerUrl directly, as is done today).
Returns hApp metadata, available read-only gateways, supported auth methods, and linker information. Unauthenticated — anyone loading the page can call this.
Response (200 OK):
{
"happ": {
"id": "mewsfeed",
"name": "Mewsfeed",
"description": "Decentralized microblogging on Holochain",
"icon_url": "https://app.example.com/icon.png"
},
"http_gateways": [
{
"url": "https://gw1.example.com",
"dna_hashes": ["uhC0k..."],
"status": "available"
}
],
"auth_methods": ["invite_code", { "any_of": ["email_code", "sms_code"] }],
"linker_info": {
"selection_mode": "assigned",
"region_hints": ["us-east", "eu-west"]
},
"happ_bundle_url": "https://app.example.com/mewsfeed.happ",
"dna_modifiers": {
"network_seed": "mewsfeed-mainnet-2026",
"properties": {}
}
}| Field | Type | Required | Description |
|---|---|---|---|
happ.id |
string | yes | Machine-readable hApp identifier |
happ.name |
string | yes | Human-readable name |
happ.description |
string | no | Short description |
happ.icon_url |
string (URL) | no | Icon for display in extension popup |
http_gateways |
array | no | Available hc-http-gw instances for read-only access before joining |
http_gateways[].url |
string (URL) | yes | Gateway base URL |
http_gateways[].dna_hashes |
string[] | yes | Base64-encoded DNA hashes served by this gateway |
http_gateways[].status |
string | yes | "available", "degraded", or "offline" |
http_gateways[].expires_at |
string (ISO 8601) | no | When this gateway entry expires. Absent means no known expiry. |
auth_methods |
AuthMethodEntry[] | yes | Supported authentication methods (see Section 7). Each entry is either an AuthMethod string or an { any_of: AuthMethod[] } group. Top-level entries are AND'd; methods within an any_of group are OR'd. |
linker_info |
object | no | Absent when the service does not manage linker relay URLs (e.g. pure membrane-proof or gateway-only deployments) |
linker_info.selection_mode |
string | if linker_info present | "assigned" (server picks linker) or "client_choice" (client picks from list) |
linker_info.region_hints |
string[] | no | Available regions for latency optimization |
happ_bundle_url |
string (URL) | no | URL to download the .happ bundle. May be absent if gated behind auth. |
dna_modifiers |
object | no | DNA modifiers to apply during installation |
dna_modifiers.network_seed |
string | no | Network seed for DNA hash computation |
dna_modifiers.properties |
object | no | DNA properties (arbitrary JSON, msgpack-encoded by client) |
network_config |
object | no | Network service URLs. Only present when network.reveal_in_info is enabled in config (default: off). Exposing these URLs publicly may increase DDoS surface area for the listed services. |
network_config.auth_server_url |
string (URL) | no | HC-Auth server URL (derived from hc_auth.url config) |
network_config.bootstrap_url |
string (URL) | no | Bootstrap server URL |
network_config.relay_url |
string (URL) | no | Relay server URL |
The client sends its agent key and optional identity claims. The server determines what verification (if any) is required.
Request:
{
"agent_key": "uhCAk...",
"claims": {
"email": "user@example.com"
}
}| Field | Type | Required | Description |
|---|---|---|---|
agent_key |
string | yes | Base64-encoded 39-byte AgentPubKey (from encodeHashToBase64()) |
claims |
object | no | Identity claims for verification |
claims.email |
string | no | Email address |
claims.phone |
string | no | Phone number (E.164 format) |
claims.evm_address |
string | no | EVM wallet address (0x-prefixed, checksummed) |
claims.solana_address |
string | no | Solana wallet address (base58) |
claims.invite_code |
string | no | Pre-issued invite code |
Response (201 Created) — verification required:
{
"session": "js_a1b2c3d4e5f6",
"status": "pending",
"challenges": [
{
"id": "ch_email_1",
"type": "email_code",
"description": "Enter the 6-digit code sent to u***@example.com",
"expires_at": "2026-02-24T12:30:00Z",
"group": "g_0"
},
{
"id": "ch_sms_1",
"type": "sms_code",
"description": "Enter the 6-digit code sent to +1***4567",
"expires_at": "2026-02-24T12:30:00Z",
"group": "g_0"
}
],
"poll_interval_ms": 2000
}Response (201 Created) — open join, ready immediately:
{
"session": "js_x9y8z7w6",
"status": "ready"
}Response (201 Created) — rejected:
{
"session": "js_r1r2r3",
"status": "rejected",
"reason": "This hApp requires an invite code"
}| Field | Type | Required | Description |
|---|---|---|---|
session |
string | yes | Opaque session token (prefixed js_) |
status |
string | yes | "ready", "pending", or "rejected" |
challenges |
array | if pending | Verification challenges to complete |
challenges[].id |
string | yes | Challenge identifier (used in verify endpoint) |
challenges[].type |
string | yes | Challenge type (matches auth_methods values) |
challenges[].description |
string | yes | Human-readable instruction for the user |
challenges[].expires_at |
string (ISO 8601) | no | When this challenge expires |
challenges[].metadata |
object | no | Type-specific data (e.g., EVM signing payload, nonce for agent_allow_list) |
challenges[].group |
string | no | OR group identifier. Challenges sharing the same group are alternatives -- completing any one satisfies the group. |
reason |
string | if rejected | Human-readable rejection reason |
poll_interval_ms |
number | if pending | Suggested polling interval in milliseconds |
Errors:
| HTTP Status | Code | Description |
|---|---|---|
| 400 | invalid_agent_key |
Agent key is not valid base64 or not 39 bytes |
| 400 | missing_claims |
Required claims for this hApp's auth method were not provided |
| 409 | agent_already_joined |
This agent key has already completed joining. Use POST /v1/reconnect instead. |
| 429 | rate_limited |
Too many join attempts |
Submit verification responses for pending challenges.
Request:
{
"challenge_id": "ch_email_1",
"response": "482916"
}| Field | Type | Required | Description |
|---|---|---|---|
challenge_id |
string | yes | Challenge ID from the join response |
response |
string | yes | Verification response (code, signature, etc.) |
For EVM signature challenges, response is the hex-encoded signature:
{
"challenge_id": "ch_evm_1",
"response": "0x1234abcd..."
}Response (200 OK) — challenge passed, more remain:
{
"status": "pending",
"challenges_remaining": [
{
"id": "ch_sms_1",
"type": "sms_code",
"description": "Enter the 6-digit code sent to +1***4567",
"expires_at": "2026-02-24T12:35:00Z"
}
],
"poll_interval_ms": 2000
}Response (200 OK) — all challenges complete:
{
"status": "ready"
}| Field | Type | Required | Description |
|---|---|---|---|
status |
string | yes | "ready", "pending", or "rejected" |
challenges_remaining |
array | if pending | Remaining challenges |
reason |
string | if rejected | Rejection reason (e.g., wrong code too many times) |
poll_interval_ms |
number | if pending | Suggested polling interval |
Errors:
| HTTP Status | Code | Description |
|---|---|---|
| 400 | invalid_response |
Response format is wrong for this challenge type |
| 401 | invalid_session |
Session token is invalid or expired |
| 404 | challenge_not_found |
Challenge ID not found for this session |
| 410 | challenge_expired |
Challenge has expired; client should POST /join again |
| 422 | verification_failed |
Response was incorrect (e.g., wrong code) |
| 429 | rate_limited |
Too many verification attempts |
Poll for the current status of a join session. Used when external processes (e.g., admin approval, async KYC) may change the status without client action.
Response (200 OK):
{
"status": "pending",
"challenges": [
{
"id": "ch_email_1",
"type": "email_code",
"description": "Enter the 6-digit code sent to u***@example.com",
"completed": false
}
],
"poll_interval_ms": 2000
}Same response shape as the join response.
Errors:
| HTTP Status | Code | Description |
|---|---|---|
| 401 | invalid_session |
Session token is invalid or expired |
| 410 | session_expired |
Session has expired entirely |
Retrieve the provision data needed to connect to the Holochain network. Only available when session status is "ready".
Response (200 OK):
{
"linker_urls": [
{ "url": "wss://linker1.example.com:8090" },
{ "url": "wss://linker2.example.com:8090", "expires_at": "2026-02-25T18:00:00Z" }
],
"membrane_proofs": {
"uhC0k_chat_dna_hash...": "gqNPa6RkYXRh...",
"uhC0k_profile_dna_hash...": "hRtYm9keW..."
},
"happ_bundle_url": "https://app.example.com/mewsfeed.happ",
"dna_modifiers": {
"network_seed": "mewsfeed-mainnet-2026",
"properties": {}
},
"network_config": {
"auth_server_url": "https://auth.example.com",
"bootstrap_url": "https://bootstrap.example.com",
"relay_url": "wss://relay.example.com"
}
}| Field | Type | Required | Description |
|---|---|---|---|
linker_urls |
LinkerUrl[] | no | Ordered list of linker URL entries (client tries in order). Absent when the service does not manage linker relay URLs. |
linker_urls[].url |
string (WSS URL) | yes | WebSocket URL for this linker relay |
linker_urls[].expires_at |
string (ISO 8601) | no | When this individual linker URL reservation expires. Absent means no known expiry. Client should call POST /v1/reconnect to obtain fresh URLs. Membrane proofs do not expire. |
membrane_proofs |
object | no | Map of DnaHash (base64-encoded, e.g. uhC0k...) to base64-encoded msgpack membrane proof bytes. One entry per DNA role that requires a membrane proof. Absent/empty if the hApp has no membrane requirement. |
happ_bundle_url |
string (URL) | no | URL to fetch the .happ bundle. May differ from /info response (gated behind auth). |
dna_modifiers |
object | no | DNA modifiers to apply during installation |
dna_modifiers.network_seed |
string | no | Network seed |
dna_modifiers.properties |
object | no | DNA properties (JSON; client encodes to msgpack) |
network_config |
object | no | Network service URLs for conductor configuration. Only present when at least one URL is available. |
network_config.auth_server_url |
string (URL) | no | HC-Auth server URL (derived from hc_auth.url config). The conductor runtime can call /now on this to obtain info for auth_material. |
network_config.bootstrap_url |
string (URL) | no | Bootstrap server URL |
network_config.relay_url |
string (URL) | no | Relay server URL |
Errors:
| HTTP Status | Code | Description |
|---|---|---|
| 401 | invalid_session |
Session token is invalid or expired |
| 403 | not_ready |
Session exists but status is not "ready" |
| 403 | agent_revoked |
Agent was blocked by administrator (hc_auth_approval revocation) |
| 410 | session_expired |
Session has expired; must start over |
An agent that has already completed joining can request updated linker URLs and gateway URLs. This is used when:
- One or more linker URL reservations have expired (per-entry
expires_athas passed) - The client has lost connectivity and needs fresh infrastructure URLs
- The pool of available linkers or gateways has changed
This endpoint does not re-run verification challenges. Instead, the agent proves key ownership by signing a timestamp with their ed25519 private key.
Request:
{
"agent_key": "uhCAk...",
"timestamp": "2026-02-25T12:00:00Z",
"signature": "base64-encoded-ed25519-signature-of-timestamp"
}| Field | Type | Required | Description |
|---|---|---|---|
agent_key |
string | yes | Base64-encoded 39-byte AgentPubKey (same key used during join) |
timestamp |
string (ISO 8601) | yes | Current UTC timestamp. Server rejects if more than 5 minutes from server time. |
signature |
string | yes | Base64-encoded ed25519 signature of the exact timestamp string, signed with the private key corresponding to agent_key |
Response (200 OK):
{
"linker_urls": [
{ "url": "wss://linker3.example.com:8090", "expires_at": "2026-02-25T18:00:00Z" },
{ "url": "wss://linker4.example.com:8090" }
],
"http_gateways": [
{
"url": "https://gw2.example.com",
"dna_hashes": ["uhC0k..."],
"status": "available"
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
linker_urls |
LinkerUrl[] | no | Updated ordered list of linker URL entries. Absent when the service does not manage linker relay URLs. Each entry may carry its own expires_at. |
http_gateways |
array | no | Current read-only gateway instances (same schema as /v1/info). Each entry may carry its own expires_at. |
Errors:
| HTTP Status | Code | Description |
|---|---|---|
| 400 | invalid_agent_key |
Agent key is not valid base64 or not 39 bytes |
| 400 | invalid_signature |
Signature does not verify against agent key |
| 400 | timestamp_out_of_range |
Timestamp is more than 5 minutes from server time |
| 403 | agent_not_joined |
This agent key has not completed joining |
| 403 | agent_revoked |
Agent was blocked by administrator (hc_auth_approval revocation) |
| 429 | rate_limited |
Too many reconnect attempts |
All errors follow a consistent JSON structure:
{
"error": {
"code": "invalid_agent_key",
"message": "Agent key must be a valid base64-encoded 39-byte HoloHash",
"details": {}
}
}| Field | Type | Required | Description |
|---|---|---|---|
error.code |
string | yes | Machine-readable error code (snake_case) |
error.message |
string | yes | Human-readable description |
error.details |
object | no | Additional type-specific context |
Standard HTTP status codes:
400— Bad request (malformed input)401— Unauthorized (invalid/expired session)403— Forbidden (session not in correct state)404— Not found409— Conflict (duplicate agent)410— Gone (expired resource)422— Unprocessable entity (verification failed)429— Too many requests500— Internal server error
The joining service must be callable from any origin (hApp UIs on arbitrary domains):
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400
Session tokens are passed in the URL path (/join/{session}/...), not in headers. This avoids preflight request complications for simple GET/POST calls.
Rate limit headers on every response:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1708776000
Retry-After: 30
Recommended limits:
| Endpoint | Limit | Scope |
|---|---|---|
GET /v1/info |
120/min | per IP |
POST /v1/join |
10/min | per IP |
POST /v1/join/{session}/verify |
5/min | per session |
GET /v1/join/{session}/provision |
30/min | per session |
GET /v1/join/{session}/status |
30/min | per session |
POST /v1/reconnect |
10/min | per agent key |
- Each session is bound to the
agent_keythat created it. Provision data is only issued for that agent. - Session tokens: cryptographically random, at least 128 bits of entropy, prefixed
js_. - Expiry: 1 hour for pending sessions, 24 hours for ready sessions.
- Server validates that
agent_keydecodes to exactly 39 bytes and starts with the AgentPubKey type prefix (0x84, 0x20, 0x24). - The server does NOT verify private key ownership — that proof happens at the Holochain network level during genesis and all subsequent signed actions.
- Generated server-side per DNA, typically includes agent key + DNA hash + timestamp + server signature.
membrane_proofsis a map of DnaHash → base64-encoded proof. Each DNA that requires a membrane proof gets its own entry.- Opaque to the client (msgpack bytes, base64 for transport).
- Each DNA's
genesis_self_checkcallback validates its own proof independently.
- All endpoints must be served over HTTPS.
- The
.well-knownendpoint must be on the same origin as the hApp UI (prevents MITM redirection).
POST /joinis aggressive (10/min) because each join may trigger email/SMS sends.- Verification attempts limited per session to prevent brute-force of codes.
| Method | Claims Required | Challenge Type | Response Format | Notes |
|---|---|---|---|---|
open |
none | none | N/A | Instant "ready" status |
email_code |
email |
6-digit code via email | numeric string | Code masked in description |
sms_code |
phone |
6-digit code via SMS | numeric string | Phone masked in description |
evm_signature |
evm_address |
Sign message | hex signature 0x... |
Signing payload in metadata |
solana_signature |
solana_address |
Sign message | base58 signature | Signing payload in metadata |
invite_code |
invite_code |
none | N/A | Validated at join time |
agent_allow_list |
none | Sign nonce | base64 ed25519 signature | Pre-approved agent keys only. Nonce in metadata.nonce. |
hc_auth_approval |
none | none (server-side) | N/A (poll /status) |
Operator/KYC approval via hc-auth server. No client-side challenge — client polls status until approved or blocked. |
x-* |
custom | custom | custom | Developer-defined methods |
Top-level entries in auth_methods are AND'd together -- the agent must satisfy every entry. An { any_of: [...] } entry creates an OR group: the agent must satisfy at least one method in the group.
Example: invite code required, plus either email or SMS verification:
{
"auth_methods": ["invite_code", { "any_of": ["email_code", "sms_code"] }]
}Challenges within the same OR group share a group field (e.g., "g_0"). The client can present these as alternatives and verify whichever the user completes.
The agent_allow_list method verifies that an agent's public key is in a pre-defined allow list. The server generates a random nonce; the agent signs it with their ed25519 private key to prove identity.
- If the agent key is not in the allow list and the method is standalone (AND), the join is immediately rejected.
- If the agent key is not in the allow list but the method is in an OR group, the other methods in the group can still satisfy it.
Config:
{
"auth_methods": ["agent_allow_list"],
"allowed_agents": ["uhCAk...base64-encoded-39-byte-AgentPubKey..."]
}Challenge metadata sent to client:
{
"metadata": {
"nonce": "base64-encoded-32-random-bytes"
}
}Verify request:
{
"challenge_id": "ch_agent_wl_1",
"response": "base64-encoded-ed25519-signature-of-nonce-bytes"
}The hc_auth_approval method delegates join decisions to the hc-auth server. No client-side challenge is issued — instead, the agent is registered as pending in hc-auth, and the client polls GET /status until an operator (or external KYC provider) approves or blocks the agent.
- On
POST /v1/join, the server registers the agent key with hc-auth inpendingstate. - If the agent is already
authorizedin hc-auth, the join succeeds immediately (no challenge). - If the agent is
blocked, the join is immediately rejected. - Otherwise, a
hc_auth_approvalchallenge is created. The client polls/status— the server live-polls hc-auth on each status request. - At provision and reconnect time, the server checks whether the agent is still authorized. If the agent has been blocked since joining, the request is rejected with
agent_revoked(403).
Config:
{
"auth_methods": ["hc_auth_approval"],
"hc_auth": {
"server_url": "https://auth.example.com",
"api_token": "secret-admin-token",
"required": true
}
}hc_auth config fields:
| Field | Type | Required | Description |
|---|---|---|---|
server_url |
string | yes | Base URL of the hc-auth-server (e.g. https://auth.example.com) |
api_token |
string | yes | Bearer token from the hc-auth-server's API_TOKENS config, used for admin API calls (transition, get) |
required |
boolean | no | If true, a failure to communicate with hc-auth blocks provisioning. Default: false (non-fatal — hc-auth outage does not break joining) |
forward_claims |
string[] | no | Claim keys to forward as metadata to hc-auth during registration (e.g. ["email", "phone"]). When set, matching claims from the join session are included in the metadata payload sent to PUT /request-auth/{pubkey}. Only useful when other auth methods collect those claims (e.g. email_code, sms_code). Default: none |
Example with forward_claims — forwarding verified email to hc-auth alongside an email code challenge:
{
"auth_methods": ["email_code"],
"hc_auth": {
"server_url": "https://auth.example.com",
"api_token": "secret-admin-token",
"forward_claims": ["email"]
}
}Can be combined in OR groups:
{
"auth_methods": [{ "any_of": ["hc_auth_approval", "invite_code"] }]
}{
"metadata": {
"sign_method": "personal_sign",
"message": "Join mewsfeed with agent uhCAk...\nNonce: x7y8z9\nTimestamp: 2026-02-24T12:00:00Z"
}
}The client uses ethers.js, viem, or wallet API to sign the message and returns the hex signature.
Client Joining Service
│ │
├─ GET /.well-known/holo-joining ────────────► │
│◄─ { joining_service_url } ───────────────────┤
│ │
├─ GET /v1/info ───────────────────────────────►│
│◄─ { auth_methods: ["open"], ... } ───────────┤
│ │
├─ POST /v1/join { agent_key } ────────────────►│
│◄─ { session, status: "ready" } ──────────────┤
│ │
├─ GET /v1/join/{session}/provision ──────────►│
│◄─ { linker_urls, happ_bundle_url } ──────────┤
│ │
├─ [fetch hApp bundle, install, connect] ──────►│
Client Joining Service
│ │
├─ GET /v1/info ───────────────────────────────►│
│◄─ { auth_methods: ["email_code"] } ──────────┤
│ │
├─ POST /v1/join │
│ { agent_key, claims: { email } } ──────────►│
│◄─ { session, status: "pending", │
│ challenges: [{ id, type: "email_code", │
│ description: "Enter code..." }] } ─────┤
│ │
│ (user checks email, gets code 482916) │
│ │
├─ POST /v1/join/{session}/verify │
│ { challenge_id, response: "482916" } ──────►│
│◄─ { status: "ready" } ───────────────────────┤
│ │
├─ GET /v1/join/{session}/provision ──────────►│
│◄─ { linker_urls, membrane_proofs } ──────────┤
Client Joining Service
│ │
├─ POST /v1/join │
│ { agent_key, claims: { evm_address } } ────►│
│◄─ { session, status: "pending", │
│ challenges: [{ id, type: "evm_signature",│
│ metadata: { sign_method, message } }] }┤
│ │
│ (user signs with MetaMask/wallet) │
│ │
├─ POST /v1/join/{session}/verify │
│ { challenge_id, response: "0x1a2b..." } ───►│
│◄─ { status: "ready" } ───────────────────────┤
│ │
├─ GET /v1/join/{session}/provision ──────────►│
│◄─ { linker_urls, membrane_proofs } ──────────┤
Client Joining Service
│ │
├─ GET /v1/info ───────────────────────────────►│
│◄─ { http_gateways: [{ url, dna_hashes }] } ─┤
│ │
├─ [route zome calls to http_gateways[0].url] ─► hc-http-gw
│◄─ [read-only results] ───────────────────────┤
│ │
│ (user decides to join) │
│ │
├─ POST /v1/join { agent_key } ────────────────►│
│ ... (normal join flow) ... │
│ │
├─ [switch from http-gw to local WASM via linker]
Agent (already joined) Joining Service
│ │
│ (linker URLs expired or connectivity │
│ lost, needs fresh URLs) │
│ │
├─ POST /v1/reconnect │
│ { agent_key: "uhCAk...", │
│ timestamp: "2026-02-25T12:00:00Z", │
│ signature: "base64..." } ────────────►│
│ │
│ (server verifies ed25519 signature │
│ and confirms agent has joined) │
│ │
│◄─ { linker_urls: ["wss://..."], │
│ http_gateways: [...], │
│ linker_urls_expire_at: "..." } ─────┤
│ │
├─ [reconnect to new linker URLs] ────────►
Client Joining Service
│ │
├─ GET /v1/info ───────────────────────────────►│
│◄─ { auth_methods: [ │
│ { any_of: ["email_code","sms_code"] } │
│ ] } ────────────────────────────────────────┤
│ │
├─ POST /v1/join │
│ { agent_key, │
│ claims: { email: "u@ex.com", │
│ phone: "+15551234" } } ─────────►│
│◄─ { session, status: "pending", │
│ challenges: [ │
│ { id: "ch_email_1", │
│ type: "email_code", group: "g_0" }, │
│ { id: "ch_sms_1", │
│ type: "sms_code", group: "g_0" } │
│ ] } ───────────────────────────────────────┤
│ │
│ (user picks email, enters code) │
│ │
├─ POST /v1/join/{session}/verify │
│ { challenge_id: "ch_email_1", │
│ response: "482916" } ─────────────────────►│
│◄─ { status: "ready" } ────────────────────────┤
│ │
│ (SMS challenge was in same group, │
│ completing either one is sufficient) │
Client Joining Service
│ │
├─ POST /v1/join { agent_key } ────────────────►│
│ │
│ (server checks agent_key is in allowed_agents)
│ │
│◄─ { session, status: "pending", │
│ challenges: [{ │
│ id: "ch_agent_al_1", │
│ type: "agent_allow_list", │
│ metadata: { nonce: "base64..." } │
│ }] } ──────────────────────────────────────┤
│ │
│ (client signs nonce with agent ed25519 key) │
│ │
├─ POST /v1/join/{session}/verify │
│ { challenge_id: "ch_agent_al_1", │
│ response: "base64-signature" } ────────────►│
│◄─ { status: "ready" } ────────────────────────┤
Client Joining Service HC-Auth Server
│ │ │
├─ POST /v1/join { agent_key } ────────────────►│ │
│ ├─ PUT /request-auth ────►│
│ │◄─ { state: "pending" } ─┤
│◄─ { session, status: "pending", │ │
│ challenges: [{ │ │
│ id: "ch_hc_approval_1", │ │
│ type: "hc_auth_approval", │ │
│ description: "Awaiting approval" }] │ │
│ } ─────────────────────────────────────────┤ │
│ │ │
│ (client polls status) │ │
├─ GET /v1/join/{session}/status ──────────────►│ │
│ ├─ GET /api/record ──────►│
│ │◄─ { state: "pending" } ─┤
│◄─ { status: "pending" } ─────────────────────┤ │
│ │ │
│ (operator approves via hc-auth console) │ │
│ │ │
├─ GET /v1/join/{session}/status ──────────────►│ │
│ ├─ GET /api/record ──────►│
│ │◄─ { state: "authorized" }
│◄─ { status: "ready" } ───────────────────────┤ │
│ │ │
├─ GET /v1/join/{session}/provision ──────────►│ │
│ ├─ GET /api/record ──────►│
│ │◄─ { state: "authorized" }
│◄─ { linker_urls, membrane_proofs } ──────────┤ │
Client Joining Service
│ │
├─ POST /v1/join { agent_key, claims: { email } }
│◄─ { session, status: "pending", │
│ challenges: [ │
│ { id: "ch_email_1", type: "email_code" },
│ { id: "ch_kyc_1", type: "x-kyc-review" }
│ ] } ─────────────────────────────────────┤
│ │
├─ POST /v1/join/{session}/verify │
│ { challenge_id: "ch_email_1", response: "482916" }
│◄─ { status: "pending", │
│ challenges_remaining: [ │
│ { id: "ch_kyc_1", type: "x-kyc-review",
│ description: "Awaiting admin review" }
│ ] } ─────────────────────────────────────┤
│ │
│ (poll while waiting for admin approval) │
├─ GET /v1/join/{session}/status ──────────────►│
│◄─ { status: "pending" } ─────────────────────┤
│ ... (repeat polling) ... │
├─ GET /v1/join/{session}/status ──────────────►│
│◄─ { status: "ready" } ───────────────────────┤
│ │
├─ GET /v1/join/{session}/provision ──────────►│
│◄─ { linker_urls, membrane_proofs } ──────────┤
These types define the API contract for client implementations:
// --- Discovery ---
interface WellKnownHoloJoining {
joining_service_url: string;
happ_id: string;
version: string;
}
// --- /v1/info ---
interface JoiningServiceInfo {
happ: {
id: string;
name: string;
description?: string;
icon_url?: string;
};
http_gateways?: HttpGateway[];
auth_methods: AuthMethodEntry[];
linker_info?: {
selection_mode: 'assigned' | 'client_choice';
region_hints?: string[];
};
happ_bundle_url?: string;
dna_modifiers?: DnaModifiers;
/** Only present when reveal_in_info is enabled in config. */
network_config?: NetworkConfig;
}
interface HttpGateway {
url: string;
dna_hashes: string[];
status: 'available' | 'degraded' | 'offline';
/** When this gateway entry expires. Absent means no known expiry. */
expires_at?: string;
}
/** A linker WebSocket URL with optional per-URL expiration. */
interface LinkerUrl {
url: string;
/** When this linker URL reservation expires. Absent means no known expiry. */
expires_at?: string;
}
/** Base64-encoded 39-byte Holochain AgentPubKey. */
type AgentPubKeyB64 = string;
type AuthMethod =
| 'open'
| 'email_code'
| 'sms_code'
| 'evm_signature'
| 'solana_signature'
| 'invite_code'
| 'agent_allow_list'
| 'hc_auth_approval'
| `x-${string}`;
interface AuthMethodGroup {
any_of: AuthMethod[];
}
type AuthMethodEntry = AuthMethod | AuthMethodGroup;
interface DnaModifiers {
network_seed?: string;
properties?: Record<string, unknown>;
}
// --- /v1/join ---
interface JoinRequest {
agent_key: string;
claims?: Record<string, string>;
}
interface JoinResponse {
session: string;
status: 'ready' | 'pending' | 'rejected';
challenges?: Challenge[];
reason?: string;
poll_interval_ms?: number;
}
interface Challenge {
id: string;
type: AuthMethod;
description: string;
expires_at?: string;
metadata?: Record<string, unknown>;
completed?: boolean;
/** Challenges sharing the same group are OR alternatives. */
group?: string;
}
// --- /v1/join/{session}/verify ---
interface VerifyRequest {
challenge_id: string;
response: string;
}
interface VerifyResponse {
status: 'ready' | 'pending' | 'rejected';
challenges_remaining?: Challenge[];
reason?: string;
poll_interval_ms?: number;
}
// --- Network config (shared by /v1/info and /v1/join/{session}/provision) ---
interface NetworkConfig {
auth_server_url?: string;
bootstrap_url?: string;
relay_url?: string;
}
// --- /v1/join/{session}/provision ---
interface JoinProvision {
/** Each entry may carry its own expires_at. Absent when service does not manage linker relay URLs. */
linker_urls?: LinkerUrl[];
membrane_proofs?: Record<string, string>;
happ_bundle_url?: string;
dna_modifiers?: DnaModifiers;
/** Network service URLs for conductor configuration. */
network_config?: NetworkConfig;
}
// --- /v1/reconnect ---
interface ReconnectRequest {
agent_key: string;
timestamp: string;
signature: string;
}
interface ReconnectResponse {
/** Each entry may carry its own expires_at. Absent when service does not manage linker relay URLs. */
linker_urls?: LinkerUrl[];
/** Each entry may carry its own expires_at. */
http_gateways?: HttpGateway[];
}
// --- Errors ---
interface ErrorResponse {
error: {
code: string;
message: string;
details?: Record<string, unknown>;
};
}