feat(chain): multi-device registry (v2.2.0-alpha1)
PR #1 of the multi-device roadmap. Adds per-device X25519 keys registered on-chain so senders can fan out envelopes across all of a recipient's physical devices — fixes the single-device limitation where a second phone / desktop loses messages as soon as the first one reads them. Chain (blockchain/): - New event types LINK_DEVICE / UNLINK_DEVICE, signed by the identity's master Ed25519. - LinkDevicePayload {x25519_pub_key, device_name} + UnlinkDevicePayload {x25519_pub_key} on the wire. - State: prefixDevice + x25519_pub → DeviceRecord{owner, name, added_at, revoked_at?}; reverse index prefixDevicesByOwner for O(k) listing. Revoke is a soft-delete — the row stays as a visible tombstone so offline clients can detect their own revocation and wipe local state. - MaxDevicesPerOwner = 10 slot cap; MaxDeviceNameLen = 64. - Strict lowercase-hex validation on x25519_pub so clients can't desync on letter case. - Same-owner re-link is a rename/refresh (recreates reverse index too — needed after a revoke). - Chain.DevicesOf(master_pub) returns the active records; empty slice for legacy identities so senders can fall back to IdentityInfo.X25519Pub. HTTP (node/): - GET /api/devices/{master_pub_or_addr} — returns {master_pub, count, devices[]}. Revoked records filtered out. - /api/identity/{pub} gains `device_count` so senders can decide upfront whether to fan out or take the legacy path. Tests (blockchain/devices_test.go): - Happy paths (1, 3 devices), foreign-owner rejection, same-owner refresh after revoke, unlink removes from active set, foreign-signer unlink rejection, idempotent double-unlink, malformed pub/name rejection, MaxDevices cap + recovery after unlink frees a slot, empty list for unknown master. Also in this commit: - deploy/single/join.sh — convenience script operators have been iterating on in this session (joiner-node bring-up + firewall port patching + Caddy opt-out). - client-app/app.json — `usesCleartextTraffic: true` on Android so installed APKs can talk to http:// dev nodes without TLS. See docs/ROADMAP.md for PRs #2..#4 (client fan-out, pairing flow, desktop Electron shell).
This commit is contained in:
@@ -510,10 +510,63 @@ func apiIdentity(q ExplorerQuery) http.HandlerFunc {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
// Multi-device (v2.2.0): stuff the active device count into the
|
||||
// identity payload so a sender can decide upfront whether to
|
||||
// fan out envelopes or fall back to the legacy single-X25519
|
||||
// path (device_count == 0).
|
||||
if q.DevicesOf != nil {
|
||||
if devs, derr := q.DevicesOf(pubKey); derr == nil {
|
||||
info.DeviceCount = len(devs)
|
||||
}
|
||||
}
|
||||
jsonOK(w, info)
|
||||
}
|
||||
}
|
||||
|
||||
// apiDevices — GET /api/devices/{master_pub_or_addr}
|
||||
//
|
||||
// Returns the active (non-revoked) devices linked to a master Ed25519
|
||||
// identity. Used by senders to fan out one envelope per device. Legacy
|
||||
// identities (count=0) should be handled by the caller by falling back
|
||||
// to IdentityInfo.X25519Pub — this endpoint is strictly the new registry.
|
||||
func apiDevices(q ExplorerQuery) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
input := strings.TrimPrefix(r.URL.Path, "/api/devices/")
|
||||
input = strings.Trim(input, "/")
|
||||
if input == "" {
|
||||
jsonErr(w, fmt.Errorf("master pubkey or address required"), 400)
|
||||
return
|
||||
}
|
||||
if q.DevicesOf == nil {
|
||||
jsonErr(w, fmt.Errorf("device registry not available"), 503)
|
||||
return
|
||||
}
|
||||
pubKey, err := resolveAccountID(q, input)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 404)
|
||||
return
|
||||
}
|
||||
recs, err := q.DevicesOf(pubKey)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
out := make([]blockchain.DeviceInfo, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, blockchain.DeviceInfo{
|
||||
X25519PubKey: rec.X25519PubKey,
|
||||
DeviceName: rec.DeviceName,
|
||||
AddedAt: rec.AddedAt,
|
||||
})
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"master_pub": pubKey,
|
||||
"count": len(out),
|
||||
"devices": out,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func apiSubmitTx(q ExplorerQuery) http.HandlerFunc {
|
||||
// The returned handler is wrapped in withSubmitTxGuards() by the caller:
|
||||
// body size is capped at MaxTxRequestBytes and per-IP rate limiting is
|
||||
|
||||
@@ -53,6 +53,10 @@ type ExplorerQuery struct {
|
||||
NetStats func() (blockchain.NetStats, error)
|
||||
RegisteredRelays func() ([]blockchain.RegisteredRelayInfo, error)
|
||||
IdentityInfo func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error)
|
||||
// DevicesOf (multi-device v2.2.0) returns the identity's non-revoked
|
||||
// device records. Empty slice if the identity hasn't linked any yet
|
||||
// — senders fall back to IdentityInfo.X25519Pub for legacy clients.
|
||||
DevicesOf func(masterPub string) ([]blockchain.DeviceRecord, error)
|
||||
ValidatorSet func() ([]string, error)
|
||||
SubmitTx func(tx *blockchain.Transaction) error
|
||||
// ConnectedPeers (optional) returns the local libp2p view of currently
|
||||
@@ -222,6 +226,7 @@ func registerExplorerAPI(mux *http.ServeMux, q ExplorerQuery) {
|
||||
mux.HandleFunc("/api/node/", apiNode(q)) // GET /api/node/{pubkey|DC...}
|
||||
mux.HandleFunc("/api/relays", apiRelays(q)) // GET /api/relays
|
||||
mux.HandleFunc("/api/identity/", apiIdentity(q)) // GET /api/identity/{pubkey|addr}
|
||||
mux.HandleFunc("/api/devices/", apiDevices(q)) // GET /api/devices/{master_pub} — multi-device registry (v2.2.0)
|
||||
mux.HandleFunc("/api/validators", apiValidators(q))// GET /api/validators
|
||||
mux.HandleFunc("/api/tx", withWriteTokenGuard(withSubmitTxGuards(apiSubmitTx(q)))) // POST /api/tx (body size + per-IP rate limit + optional token gate)
|
||||
// Live event stream (SSE) — GET /api/events
|
||||
|
||||
Reference in New Issue
Block a user