web/backend/secretapi/app.go
Joshua 0040620649
Some checks are pending
check / secretapi (push) Waiting to run
Block tx-hash replay across membership and checkout confirms
2026-02-19 14:23:57 -08:00

1585 lines
56 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
var (
reDigits = regexp.MustCompile(`^[0-9]+$`)
)
type app struct {
cfg Config
store *store
}
func newApp(cfg Config, st *store) *app {
return &app{cfg: cfg, store: st}
}
func (a *app) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", a.withCORS(a.handleHealth))
mux.HandleFunc("/secret/wallet/intent", a.withCORS(a.handleWalletIntent))
mux.HandleFunc("/secret/wallet/verify", a.withCORS(a.handleWalletVerify))
mux.HandleFunc("/secret/wallet/session/refresh", a.withCORS(a.handleWalletSessionRefresh))
mux.HandleFunc("/secret/wallet/session/revoke", a.withCORS(a.handleWalletSessionRevoke))
mux.HandleFunc("/secret/membership/quote", a.withCORS(a.handleMembershipQuote))
mux.HandleFunc("/secret/membership/confirm", a.withCORS(a.handleMembershipConfirm))
mux.HandleFunc("/secret/membership/status", a.withCORS(a.handleMembershipStatus))
mux.HandleFunc("/marketplace/offers", a.withCORS(a.handleMarketplaceOffers))
mux.HandleFunc("/marketplace/offers/", a.withCORS(a.handleMarketplaceOfferByID))
mux.HandleFunc("/marketplace/checkout/quote", a.withCORS(a.handleMarketplaceCheckoutQuote))
mux.HandleFunc("/marketplace/checkout/confirm", a.withCORS(a.handleMarketplaceCheckoutConfirm))
mux.HandleFunc("/marketplace/entitlements", a.withCORS(a.handleMarketplaceEntitlements))
mux.HandleFunc("/governance/install/token", a.withCORS(a.handleGovernanceInstallToken))
mux.HandleFunc("/governance/install/confirm", a.withCORS(a.handleGovernanceInstallConfirm))
mux.HandleFunc("/governance/install/status", a.withCORS(a.handleGovernanceInstallStatus))
mux.HandleFunc("/governance/lease/heartbeat", a.withCORS(a.handleGovernanceLeaseHeartbeat))
mux.HandleFunc("/governance/lease/offline-renew", a.withCORS(a.handleGovernanceOfflineRenew))
mux.HandleFunc("/member/channel/device/register", a.withCORS(a.handleMemberChannelDeviceRegister))
mux.HandleFunc("/member/channel/device/unregister", a.withCORS(a.handleMemberChannelDeviceUnregister))
mux.HandleFunc("/member/channel/events", a.withCORS(a.handleMemberChannelEvents))
mux.HandleFunc("/member/channel/events/", a.withCORS(a.handleMemberChannelEventAck))
mux.HandleFunc("/member/channel/support/ticket", a.withCORS(a.handleMemberChannelSupportTicket))
return mux
}
func (a *app) withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
origin = a.cfg.AllowedOrigin
}
if a.cfg.AllowedOrigin == "*" || strings.EqualFold(origin, a.cfg.AllowedOrigin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}
func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (a *app) handleWalletIntent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req walletIntentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
address, err := normalizeAddress(req.Address)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.ChainID != a.cfg.ChainID {
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported chain_id: %d", req.ChainID))
return
}
intentID, err := randomHex(16)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate intent id")
return
}
nonce, err := randomHex(16)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate nonce")
return
}
code, displayToken, err := newDesignationCode()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate designation code")
return
}
issuedAt := time.Now().UTC()
expiresAt := issuedAt.Add(a.cfg.IntentTTL)
record := designationRecord{
Code: code,
DisplayToken: displayToken,
IntentID: intentID,
Nonce: nonce,
Origin: strings.TrimSpace(req.Origin),
Locale: strings.TrimSpace(req.Locale),
Address: address,
ChainID: req.ChainID,
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
MembershipStatus: "none",
IdentityAssurance: assuranceNone,
}
if record.Origin == "" {
record.Origin = "https://edut.ai"
}
if record.Locale == "" {
record.Locale = "en"
}
if err := a.store.putDesignation(r.Context(), record); err != nil {
writeError(w, http.StatusInternalServerError, "failed to persist designation intent")
return
}
writeJSON(w, http.StatusOK, walletIntentResponse{
IntentID: intentID,
DesignationCode: code,
DisplayToken: displayToken,
Nonce: nonce,
IssuedAt: issuedAt.Format(time.RFC3339Nano),
ExpiresAt: expiresAt.Format(time.RFC3339Nano),
DomainName: a.cfg.DomainName,
ChainID: a.cfg.ChainID,
VerifyingContract: a.cfg.VerifyingContract,
})
}
func (a *app) handleWalletVerify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req walletVerifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
address, err := normalizeAddress(req.Address)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.ChainID != a.cfg.ChainID {
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported chain_id: %d", req.ChainID))
return
}
rec, err := a.store.getDesignationByIntent(r.Context(), strings.TrimSpace(req.IntentID))
if err != nil {
if errors.Is(err, errNotFound) {
writeError(w, http.StatusNotFound, "intent not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load intent")
return
}
if time.Now().UTC().After(rec.ExpiresAt) {
writeError(w, http.StatusBadRequest, "intent expired")
return
}
if strings.ToLower(rec.Address) != address {
writeError(w, http.StatusBadRequest, "address does not match intent")
return
}
typedData := buildTypedData(a.cfg, rec)
recovered, err := recoverSignerAddress(typedData, req.Signature)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("signature verification failed: %v", err))
return
}
if recovered != address {
writeError(w, http.StatusBadRequest, "signature signer does not match address")
return
}
now := time.Now().UTC()
rec.VerifiedAt = &now
if err := a.store.putDesignation(r.Context(), rec); err != nil {
writeError(w, http.StatusInternalServerError, "failed to store verification status")
return
}
session, err := a.issueWalletSession(r.Context(), rec.Address, rec.Code)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "session_issue_failed", "failed to issue wallet session")
return
}
w.Header().Set(sessionHeaderToken, session.SessionToken)
w.Header().Set(sessionHeaderExpiresAt, session.ExpiresAt.UTC().Format(time.RFC3339Nano))
writeJSON(w, http.StatusOK, walletVerifyResponse{
Status: "signature_verified",
DesignationCode: rec.Code,
DisplayToken: rec.DisplayToken,
VerifiedAt: now.Format(time.RFC3339Nano),
SessionToken: session.SessionToken,
SessionExpires: session.ExpiresAt.UTC().Format(time.RFC3339Nano),
})
}
func (a *app) handleWalletSessionRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req walletSessionRefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
oldToken := sessionTokenFromRequest(r)
if strings.TrimSpace(oldToken) == "" {
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_required", "wallet session required")
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
oldSession, err := a.store.getWalletSession(r.Context(), oldToken)
if err != nil {
if err == errNotFound {
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_invalid", "wallet session not found")
return
}
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve wallet session")
return
}
now := time.Now().UTC()
if err := a.store.revokeWalletSession(r.Context(), oldToken, now); err != nil && err != errNotFound {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to revoke old wallet session")
return
}
newSession, err := a.issueWalletSession(r.Context(), wallet, oldSession.DesignationCode)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "session_issue_failed", "failed to issue wallet session")
return
}
w.Header().Set(sessionHeaderToken, newSession.SessionToken)
w.Header().Set(sessionHeaderExpiresAt, newSession.ExpiresAt.UTC().Format(time.RFC3339Nano))
writeJSON(w, http.StatusOK, walletSessionRefreshResponse{
Status: "session_refreshed",
Wallet: wallet,
SessionToken: newSession.SessionToken,
SessionExpire: newSession.ExpiresAt.UTC().Format(time.RFC3339Nano),
})
}
func (a *app) handleWalletSessionRevoke(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req walletSessionRevokeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
sessionToken := sessionTokenFromRequest(r)
if strings.TrimSpace(sessionToken) == "" {
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_required", "wallet session required")
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
now := time.Now().UTC()
if err := a.store.revokeWalletSession(r.Context(), sessionToken, now); err != nil {
if err == errNotFound {
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_invalid", "wallet session not found")
return
}
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to revoke wallet session")
return
}
writeJSON(w, http.StatusOK, walletSessionRevokeResponse{
Status: "session_revoked",
Wallet: wallet,
RevokedAt: now.Format(time.RFC3339Nano),
})
}
func (a *app) handleMembershipQuote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req membershipQuoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
address, err := normalizeAddress(req.Address)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.ChainID != a.cfg.ChainID {
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported chain_id: %d", req.ChainID))
return
}
payerAddress := address
if strings.TrimSpace(req.PayerWallet) != "" {
payerAddress, err = normalizeAddress(req.PayerWallet)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
rec, err := a.store.getDesignationByCode(r.Context(), strings.TrimSpace(req.DesignationCode))
if err != nil {
if errors.Is(err, errNotFound) {
writeError(w, http.StatusNotFound, "designation not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load designation")
return
}
if rec.VerifiedAt == nil {
writeError(w, http.StatusConflict, "designation not verified")
return
}
if strings.ToLower(rec.Address) != address {
writeError(w, http.StatusBadRequest, "address does not match designation")
return
}
sponsorshipMode := "self"
if payerAddress != address {
sponsorOrgRoot := strings.TrimSpace(req.SponsorOrgRoot)
if strings.TrimSpace(req.PayerProof) != "" {
if err := verifyDistinctPayerProof(rec.Code, address, payerAddress, req.ChainID, req.PayerProof); err != nil {
writeError(w, http.StatusForbidden, fmt.Sprintf("invalid ownership proof: %v", err))
return
}
sponsorshipMode = "sponsored"
} else if sponsorOrgRoot != "" {
payerPrincipal, principalErr := a.store.getGovernancePrincipal(r.Context(), payerAddress)
if principalErr != nil {
writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor wallet is not authorized for org root")
return
}
if !strings.EqualFold(strings.TrimSpace(payerPrincipal.OrgRootID), sponsorOrgRoot) {
writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor org root mismatch")
return
}
if !strings.EqualFold(strings.TrimSpace(payerPrincipal.PrincipalRole), "org_root_owner") {
writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor wallet requires org_root_owner role")
return
}
if !strings.EqualFold(strings.TrimSpace(payerPrincipal.EntitlementStatus), "active") {
writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor entitlement is not active")
return
}
hasWorkspaceCore, entErr := a.store.hasActiveEntitlement(r.Context(), payerAddress, offerIDWorkspaceCore, sponsorOrgRoot)
if entErr != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to validate sponsor entitlement")
return
}
if !hasWorkspaceCore {
writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "workspace core entitlement required for sponsored member onboarding")
return
}
sponsorshipMode = "sponsored_company"
} else {
writeError(w, http.StatusForbidden, "distinct payer requires ownership proof")
return
}
}
quoteID, err := randomHex(16)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate quote id")
return
}
calldata, err := encodeMintMembershipCalldata(address)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to encode mint calldata")
return
}
now := time.Now().UTC()
expiresAt := now.Add(a.cfg.QuoteTTL)
valueHex := "0x0"
if strings.EqualFold(a.cfg.MintCurrency, "ETH") {
amount, ok := new(big.Int).SetString(a.cfg.MintAmountAtomic, 10)
if !ok {
writeError(w, http.StatusInternalServerError, "invalid mint amount configuration")
return
}
valueHex = "0x" + amount.Text(16)
}
quote := quoteRecord{
QuoteID: quoteID,
DesignationCode: rec.Code,
Address: address,
PayerAddress: payerAddress,
ChainID: a.cfg.ChainID,
Currency: a.cfg.MintCurrency,
AmountAtomic: a.cfg.MintAmountAtomic,
Decimals: a.cfg.MintDecimals,
ContractAddress: strings.ToLower(a.cfg.MembershipContract),
Method: "mintMembership(address)",
Calldata: calldata,
ValueHex: valueHex,
SponsorshipMode: sponsorshipMode,
SponsorOrgRootID: strings.TrimSpace(req.SponsorOrgRoot),
CreatedAt: now,
ExpiresAt: expiresAt,
}
if err := a.store.putQuote(r.Context(), quote); err != nil {
writeError(w, http.StatusInternalServerError, "failed to persist quote")
return
}
tx := map[string]any{
"from": payerAddress,
"to": quote.ContractAddress,
"data": quote.Calldata,
"value": quote.ValueHex,
}
writeJSON(w, http.StatusOK, membershipQuoteResponse{
QuoteID: quote.QuoteID,
ChainID: quote.ChainID,
Currency: quote.Currency,
AmountAtomic: quote.AmountAtomic,
Decimals: quote.Decimals,
Deadline: quote.ExpiresAt.Format(time.RFC3339Nano),
ContractAddress: quote.ContractAddress,
Method: quote.Method,
Calldata: quote.Calldata,
Value: quote.ValueHex,
OwnerWallet: address,
PayerWallet: payerAddress,
SponsorshipMode: sponsorshipMode,
SponsorOrgRoot: strings.TrimSpace(req.SponsorOrgRoot),
Tx: tx,
})
}
func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req membershipConfirmRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
address, err := normalizeAddress(req.Address)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.ChainID != a.cfg.ChainID {
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported chain_id: %d", req.ChainID))
return
}
if !isTxHash(req.TxHash) {
writeError(w, http.StatusBadRequest, "invalid tx_hash")
return
}
quote, err := a.store.getQuote(r.Context(), strings.TrimSpace(req.QuoteID))
if err != nil {
if errors.Is(err, errNotFound) {
writeError(w, http.StatusNotFound, "quote not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load quote")
return
}
if time.Now().UTC().After(quote.ExpiresAt) {
writeError(w, http.StatusConflict, "quote expired")
return
}
if quote.ConfirmedAt != nil {
writeError(w, http.StatusConflict, "quote already confirmed")
return
}
if !strings.EqualFold(quote.Address, address) || !strings.EqualFold(quote.DesignationCode, req.DesignationCode) || quote.ChainID != req.ChainID {
writeError(w, http.StatusBadRequest, "quote context mismatch")
return
}
if existingCode, lookupErr := a.store.getDesignationCodeByMembershipTxHash(r.Context(), req.TxHash); lookupErr == nil {
if !strings.EqualFold(existingCode, quote.DesignationCode) {
writeErrorCode(w, http.StatusConflict, "tx_hash_replay", "tx hash already used for a different membership confirmation")
return
}
} else if lookupErr != errNotFound {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve membership tx hash reuse")
return
}
if a.cfg.RequireOnchainTxVerify && strings.TrimSpace(a.cfg.ChainRPCURL) == "" {
writeErrorCode(w, http.StatusServiceUnavailable, "chain_verification_unavailable", "chain rpc required for membership confirmation")
return
}
if strings.TrimSpace(quote.PayerAddress) == "" {
quote.PayerAddress = quote.Address
}
if err := verifyTransactionCallOnChain(
context.Background(),
a.cfg,
req.TxHash,
quote.PayerAddress,
quote.ContractAddress,
quote.Calldata,
quote.ValueHex,
); err != nil {
writeError(w, http.StatusConflict, fmt.Sprintf("tx verification pending/failed: %v", err))
return
}
if err := verifyMintedOnChain(context.Background(), a.cfg, req.TxHash, address); err != nil {
writeError(w, http.StatusConflict, fmt.Sprintf("tx verification pending/failed: %v", err))
return
}
rec, err := a.store.getDesignationByCode(r.Context(), quote.DesignationCode)
if err != nil {
if errors.Is(err, errNotFound) {
writeError(w, http.StatusNotFound, "designation not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load designation")
return
}
now := time.Now().UTC()
identityAssurance, identityAttestedBy, identityAttestationID, identityAttestedAt, assuranceErr := resolveMembershipAssurance(quote, req, now)
if assuranceErr != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_identity_assurance", assuranceErr.Error())
return
}
rec.MembershipStatus = "active"
rec.MembershipTxHash = strings.ToLower(req.TxHash)
rec.ActivatedAt = &now
rec.IdentityAssurance = identityAssurance
rec.IdentityAttestedBy = identityAttestedBy
rec.IdentityAttestationID = identityAttestationID
rec.IdentityAttestedAt = identityAttestedAt
if err := a.store.putDesignation(r.Context(), rec); err != nil {
writeError(w, http.StatusInternalServerError, "failed to persist membership activation")
return
}
quote.ConfirmedAt = &now
quote.ConfirmedTxHash = strings.ToLower(req.TxHash)
if err := a.store.putQuote(r.Context(), quote); err != nil {
writeError(w, http.StatusInternalServerError, "failed to persist quote confirmation")
return
}
writeJSON(w, http.StatusOK, membershipConfirmResponse{
Status: "membership_active",
DesignationCode: rec.Code,
DisplayToken: rec.DisplayToken,
TxHash: strings.ToLower(req.TxHash),
ActivatedAt: now.Format(time.RFC3339Nano),
IdentityAssurance: rec.IdentityAssurance,
IdentityAttestedBy: rec.IdentityAttestedBy,
IdentityAttestationID: rec.IdentityAttestationID,
})
}
func (a *app) handleMembershipStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
wallet := strings.TrimSpace(r.URL.Query().Get("wallet"))
code := strings.TrimSpace(r.URL.Query().Get("designation_code"))
if wallet == "" && code == "" {
writeError(w, http.StatusBadRequest, "wallet or designation_code required")
return
}
var (
rec designationRecord
err error
)
if code != "" {
rec, err = a.store.getDesignationByCode(r.Context(), code)
} else {
normalized, normalizeErr := normalizeAddress(wallet)
if normalizeErr != nil {
writeError(w, http.StatusBadRequest, normalizeErr.Error())
return
}
rec, err = a.store.getDesignationByAddress(r.Context(), normalized)
}
if err != nil {
if errors.Is(err, errNotFound) {
writeJSON(w, http.StatusOK, membershipStatusResponse{Status: "none"})
return
}
writeError(w, http.StatusInternalServerError, "failed to resolve membership status")
return
}
status := strings.ToLower(strings.TrimSpace(rec.MembershipStatus))
if status == "" {
status = "none"
}
writeJSON(w, http.StatusOK, membershipStatusResponse{
Status: status,
Wallet: rec.Address,
DesignationCode: rec.Code,
IdentityAssurance: normalizeAssuranceLevel(rec.IdentityAssurance),
IdentityAttestedBy: strings.TrimSpace(rec.IdentityAttestedBy),
IdentityAttestationID: strings.TrimSpace(rec.IdentityAttestationID),
})
}
func (a *app) handleGovernanceInstallToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req governanceInstallTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
if strings.TrimSpace(req.DeviceID) == "" || strings.TrimSpace(req.Platform) == "" || strings.TrimSpace(req.LauncherVersion) == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id, platform, launcher_version required")
return
}
rec, err := a.store.getDesignationByAddress(r.Context(), wallet)
if err != nil {
if errors.Is(err, errNotFound) {
writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership not active")
return
}
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve membership")
return
}
if strings.ToLower(strings.TrimSpace(rec.MembershipStatus)) != "active" {
writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership not active")
return
}
if !isOnrampAttested(rec.IdentityAssurance) {
writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "workspace admin operations require onramp_attested identity assurance")
return
}
principal, err := a.resolveOrCreatePrincipal(r.Context(), wallet, req.OrgRootID, req.PrincipalID, req.PrincipalRole)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "principal_resolve_failed", err.Error())
return
}
if !strings.EqualFold(strings.TrimSpace(principal.PrincipalRole), "org_root_owner") {
writeErrorCode(w, http.StatusForbidden, "role_insufficient", "install/update controls require org_root_owner")
return
}
if strings.ToLower(strings.TrimSpace(principal.EntitlementStatus)) != "active" {
writeErrorCode(w, http.StatusForbidden, "entitlement_inactive", "governance entitlement inactive")
return
}
if strings.ToLower(strings.TrimSpace(principal.AvailabilityState)) == "parked" {
writeErrorCode(w, http.StatusForbidden, "availability_parked", "availability state parked blocks activation")
return
}
installToken, err := randomHex(24)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "token_generation_failed", "failed to issue install token")
return
}
now := time.Now().UTC()
expiresAt := now.Add(a.cfg.InstallTokenTTL)
tokenRec := governanceInstallTokenRecord{
InstallToken: installToken,
Wallet: wallet,
OrgRootID: principal.OrgRootID,
PrincipalID: principal.PrincipalID,
PrincipalRole: principal.PrincipalRole,
DeviceID: strings.TrimSpace(req.DeviceID),
EntitlementID: principal.EntitlementID,
PackageHash: a.cfg.GovernancePackageHash,
RuntimeVersion: a.cfg.GovernanceRuntimeVersion,
PolicyHash: a.cfg.GovernancePolicyHash,
IssuedAt: now,
ExpiresAt: expiresAt,
}
if err := a.store.putGovernanceInstallToken(r.Context(), tokenRec); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist install token")
return
}
writeJSON(w, http.StatusOK, governanceInstallTokenResponse{
InstallToken: installToken,
InstallTokenExpiresAt: expiresAt.Format(time.RFC3339Nano),
Wallet: wallet,
EntitlementID: principal.EntitlementID,
Package: governancePackage{
RuntimeVersion: a.cfg.GovernanceRuntimeVersion,
PackageURL: a.cfg.GovernancePackageURL,
PackageHash: a.cfg.GovernancePackageHash,
Signature: a.cfg.GovernancePackageSig,
SignerKeyID: a.cfg.GovernanceSignerKeyID,
PolicyHash: a.cfg.GovernancePolicyHash,
RolloutChannel: a.cfg.GovernanceRolloutChannel,
},
})
}
func (a *app) handleGovernanceInstallConfirm(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req governanceInstallConfirmRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
if strings.TrimSpace(req.InstallToken) == "" || strings.TrimSpace(req.DeviceID) == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "install_token and device_id required")
return
}
tokenRec, err := a.store.getGovernanceInstallToken(r.Context(), strings.TrimSpace(req.InstallToken))
if err != nil {
if errors.Is(err, errNotFound) {
writeErrorCode(w, http.StatusNotFound, "install_token_not_found", "install token not found")
return
}
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to load install token")
return
}
if time.Now().UTC().After(tokenRec.ExpiresAt) {
writeErrorCode(w, http.StatusConflict, "install_token_expired", "install token expired")
return
}
if tokenRec.ConsumedAt != nil {
if existing, existingErr := a.store.getGovernanceInstallByToken(r.Context(), tokenRec.InstallToken); existingErr == nil {
writeJSON(w, http.StatusOK, governanceInstallConfirmResponse{
Status: "governance_active",
Wallet: existing.Wallet,
DeviceID: existing.DeviceID,
EntitlementID: existing.EntitlementID,
RuntimeVersion: existing.RuntimeVersion,
ActivatedAt: existing.ActivatedAt.Format(time.RFC3339Nano),
})
return
}
writeErrorCode(w, http.StatusConflict, "install_token_consumed", "install token already consumed")
return
}
if tokenRec.Wallet != wallet || tokenRec.DeviceID != strings.TrimSpace(req.DeviceID) {
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "install token context mismatch")
return
}
if !strings.EqualFold(strings.TrimSpace(req.EntitlementID), tokenRec.EntitlementID) {
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "entitlement mismatch")
return
}
if !strings.EqualFold(strings.TrimSpace(req.PackageHash), tokenRec.PackageHash) {
writeErrorCode(w, http.StatusConflict, "package_hash_mismatch", "package hash mismatch")
return
}
if !strings.EqualFold(strings.TrimSpace(req.RuntimeVersion), tokenRec.RuntimeVersion) {
writeErrorCode(w, http.StatusConflict, "runtime_version_mismatch", "runtime version mismatch")
return
}
principal, err := a.store.getGovernancePrincipal(r.Context(), wallet)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "principal_lookup_failed", "failed to resolve principal")
return
}
if strings.ToLower(strings.TrimSpace(principal.EntitlementStatus)) != "active" {
writeErrorCode(w, http.StatusConflict, "entitlement_inactive", "entitlement inactive")
return
}
if strings.ToLower(strings.TrimSpace(principal.AvailabilityState)) == "parked" {
writeErrorCode(w, http.StatusConflict, "availability_parked", "availability parked")
return
}
installedAt := time.Now().UTC()
if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.InstalledAt)); err == nil {
installedAt = parsed.UTC()
}
activatedAt := time.Now().UTC()
installRec := governanceInstallRecord{
InstallToken: tokenRec.InstallToken,
Wallet: wallet,
DeviceID: strings.TrimSpace(req.DeviceID),
EntitlementID: tokenRec.EntitlementID,
RuntimeVersion: tokenRec.RuntimeVersion,
PackageHash: tokenRec.PackageHash,
PolicyHash: tokenRec.PolicyHash,
LauncherReceiptRef: strings.TrimSpace(req.LauncherReceiptRef),
InstalledAt: installedAt,
ActivatedAt: activatedAt,
}
if err := a.store.putGovernanceInstall(r.Context(), installRec); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist install confirmation")
return
}
tokenRec.ConsumedAt = &activatedAt
if err := a.store.putGovernanceInstallToken(r.Context(), tokenRec); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to mark install token consumed")
return
}
writeJSON(w, http.StatusOK, governanceInstallConfirmResponse{
Status: "governance_active",
Wallet: wallet,
DeviceID: installRec.DeviceID,
EntitlementID: installRec.EntitlementID,
RuntimeVersion: installRec.RuntimeVersion,
ActivatedAt: activatedAt.Format(time.RFC3339Nano),
})
}
func (a *app) handleGovernanceInstallStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
walletRaw := strings.TrimSpace(r.URL.Query().Get("wallet"))
deviceID := strings.TrimSpace(r.URL.Query().Get("device_id"))
wallet, err := normalizeAddress(walletRaw)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
membershipStatus := "none"
identityAssurance := assuranceNone
if rec, err := a.store.getDesignationByAddress(r.Context(), wallet); err == nil {
membershipStatus = strings.ToLower(strings.TrimSpace(rec.MembershipStatus))
if membershipStatus == "" {
membershipStatus = "none"
}
identityAssurance = normalizeAssuranceLevel(rec.IdentityAssurance)
}
resp := governanceInstallStatusResponse{
Wallet: wallet,
MembershipStatus: membershipStatus,
IdentityAssurance: identityAssurance,
EntitlementStatus: "unknown",
AccessClass: "unknown",
AvailabilityState: "unknown",
ActivationStatus: "not_installed",
LatestRuntimeVersion: a.cfg.GovernanceRuntimeVersion,
PolicyHash: a.cfg.GovernancePolicyHash,
}
principal, err := a.store.getGovernancePrincipal(r.Context(), wallet)
if err == nil {
resp.OrgRootID = principal.OrgRootID
resp.PrincipalID = principal.PrincipalID
resp.PrincipalRole = principal.PrincipalRole
resp.EntitlementStatus = strings.ToLower(strings.TrimSpace(principal.EntitlementStatus))
resp.AccessClass = strings.ToLower(strings.TrimSpace(principal.AccessClass))
resp.AvailabilityState = strings.ToLower(strings.TrimSpace(principal.AvailabilityState))
} else if !errors.Is(err, errNotFound) {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to load principal status")
return
}
if deviceID != "" {
if installRec, err := a.store.getGovernanceInstallByDevice(r.Context(), wallet, deviceID); err == nil {
resp.ActivationStatus = "active"
resp.LatestRuntimeVersion = installRec.RuntimeVersion
}
}
if resp.AvailabilityState == "parked" {
resp.ActivationStatus = "blocked"
resp.Reason = "availability_parked"
}
if resp.EntitlementStatus != "" && resp.EntitlementStatus != "unknown" && resp.EntitlementStatus != "active" {
resp.ActivationStatus = "blocked"
resp.Reason = "entitlement_inactive"
}
if resp.MembershipStatus != "active" {
resp.Reason = "membership_inactive"
}
writeJSON(w, http.StatusOK, resp)
}
func (a *app) handleGovernanceLeaseHeartbeat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req governanceLeaseHeartbeatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
principal, err := a.store.getGovernancePrincipal(r.Context(), wallet)
if err != nil {
writeErrorCode(w, http.StatusForbidden, "principal_missing", "principal state missing")
return
}
if principal.OrgRootID != strings.TrimSpace(req.OrgRootID) || principal.PrincipalID != strings.TrimSpace(req.PrincipalID) {
writeErrorCode(w, http.StatusForbidden, "boundary_mismatch", "principal boundary mismatch")
return
}
if strings.ToLower(strings.TrimSpace(principal.EntitlementStatus)) != "active" {
writeErrorCode(w, http.StatusForbidden, "entitlement_inactive", "entitlement inactive")
return
}
leaseExpires := time.Now().UTC().Add(a.cfg.LeaseTTL)
principal.LeaseExpiresAt = &leaseExpires
principal.AvailabilityState = "active"
principal.UpdatedAt = time.Now().UTC()
if err := a.store.putGovernancePrincipal(r.Context(), principal); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist lease heartbeat")
return
}
writeJSON(w, http.StatusOK, governanceLeaseHeartbeatResponse{
Status: "lease_refreshed",
AvailabilityState: "active",
LeaseExpiresAt: leaseExpires.Format(time.RFC3339Nano),
})
}
func (a *app) handleGovernanceOfflineRenew(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req governanceOfflineRenewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
principal, err := a.store.getGovernancePrincipal(r.Context(), wallet)
if err != nil {
writeErrorCode(w, http.StatusForbidden, "principal_missing", "principal state missing")
return
}
if principal.OrgRootID != strings.TrimSpace(req.OrgRootID) || principal.PrincipalID != strings.TrimSpace(req.PrincipalID) {
writeErrorCode(w, http.StatusForbidden, "boundary_mismatch", "principal boundary mismatch")
return
}
if strings.ToLower(strings.TrimSpace(principal.EntitlementStatus)) != "active" {
writeErrorCode(w, http.StatusForbidden, "entitlement_inactive", "entitlement inactive")
return
}
isSoloRoot := strings.EqualFold(strings.TrimSpace(principal.OrgRootID), defaultSoloOrgRootID(wallet))
requiredOfferID := offerIDWorkspaceSovereign
if isSoloRoot {
requiredOfferID = offerIDSoloCore
}
hasRequired, entErr := a.store.hasActiveEntitlement(r.Context(), wallet, requiredOfferID, principal.OrgRootID)
if entErr != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve sovereign entitlement")
return
}
if !hasRequired {
writeErrorCode(w, http.StatusForbidden, "sovereign_entitlement_required", "sovereign entitlement required for offline renew")
return
}
renewedUntil := time.Now().UTC().Add(a.cfg.OfflineRenewTTL)
principal.AccessClass = "sovereign"
principal.AvailabilityState = "active"
principal.LeaseExpiresAt = &renewedUntil
principal.UpdatedAt = time.Now().UTC()
if err := a.store.putGovernancePrincipal(r.Context(), principal); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist offline renewal")
return
}
writeJSON(w, http.StatusOK, governanceOfflineRenewResponse{
Status: "renewal_applied",
AvailabilityState: "active",
RenewedUntil: renewedUntil.Format(time.RFC3339Nano),
})
}
func (a *app) handleMemberChannelDeviceRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req memberChannelDeviceRegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
if req.ChainID != a.cfg.ChainID {
writeErrorCode(w, http.StatusBadRequest, "unsupported_chain_id", fmt.Sprintf("unsupported chain_id: %d", req.ChainID))
return
}
req.DeviceID = strings.TrimSpace(req.DeviceID)
req.Platform = strings.ToLower(strings.TrimSpace(req.Platform))
req.OrgRootID = strings.TrimSpace(req.OrgRootID)
req.PrincipalID = strings.TrimSpace(req.PrincipalID)
req.PrincipalRole = strings.ToLower(strings.TrimSpace(req.PrincipalRole))
req.AppVersion = strings.TrimSpace(req.AppVersion)
req.PushProvider = strings.ToLower(strings.TrimSpace(req.PushProvider))
req.PushToken = strings.TrimSpace(req.PushToken)
if req.DeviceID == "" || req.Platform == "" || req.OrgRootID == "" || req.PrincipalID == "" || req.PrincipalRole == "" || req.AppVersion == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id, platform, org_root_id, principal_id, principal_role, app_version required")
return
}
if req.PushProvider == "" {
req.PushProvider = "none"
}
rec, err := a.store.getDesignationByAddress(r.Context(), wallet)
if err != nil || !strings.EqualFold(strings.TrimSpace(rec.MembershipStatus), "active") {
writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership_inactive")
return
}
now := time.Now().UTC()
channelBindingID, err := randomHex(12)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "binding_generation_failed", "failed to create channel binding")
return
}
binding := memberChannelBindingRecord{
ChannelBindingID: "ch_" + channelBindingID,
Wallet: wallet,
ChainID: req.ChainID,
DeviceID: req.DeviceID,
Platform: req.Platform,
OrgRootID: req.OrgRootID,
PrincipalID: req.PrincipalID,
PrincipalRole: req.PrincipalRole,
AppVersion: req.AppVersion,
PushProvider: req.PushProvider,
PushToken: req.PushToken,
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
if err := a.store.putMemberChannelBinding(r.Context(), binding); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist channel binding")
return
}
_ = a.seedMemberChannelEvents(r.Context(), binding)
writeJSON(w, http.StatusOK, memberChannelDeviceRegisterResponse{
ChannelBindingID: binding.ChannelBindingID,
Status: "active",
PollIntervalSeconds: a.cfg.MemberPollIntervalSec,
ServerTime: now.Format(time.RFC3339Nano),
})
}
func (a *app) handleMemberChannelDeviceUnregister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req memberChannelDeviceUnregisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
req.DeviceID = strings.TrimSpace(req.DeviceID)
if req.DeviceID == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required")
return
}
if err := a.store.removeMemberChannelBinding(r.Context(), wallet, req.DeviceID, time.Now().UTC()); err != nil {
if errors.Is(err, errNotFound) {
writeErrorCode(w, http.StatusNotFound, "channel_binding_not_found", "device channel binding not found")
return
}
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to remove channel binding")
return
}
writeJSON(w, http.StatusOK, memberChannelDeviceUnregisterResponse{
Status: "removed",
Wallet: wallet,
DeviceID: req.DeviceID,
})
}
func (a *app) handleMemberChannelEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
wallet, err := normalizeAddress(strings.TrimSpace(r.URL.Query().Get("wallet")))
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
deviceID := strings.TrimSpace(r.URL.Query().Get("device_id"))
if deviceID == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required")
return
}
limit := 25
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, parseErr := strconv.Atoi(raw)
if parseErr != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid limit")
return
}
if parsed < 1 {
parsed = 1
}
if parsed > 100 {
parsed = 100
}
limit = parsed
}
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
rec, err := a.store.getDesignationByAddress(r.Context(), wallet)
if err != nil || !strings.EqualFold(strings.TrimSpace(rec.MembershipStatus), "active") {
writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership_inactive")
return
}
binding, err := a.store.getMemberChannelBinding(r.Context(), wallet, deviceID)
if err != nil {
writeErrorCode(w, http.StatusForbidden, "channel_binding_missing", "channel binding missing")
return
}
ownerVisible := strings.EqualFold(strings.TrimSpace(binding.PrincipalRole), "org_root_owner")
events, nextCursor, err := a.store.listMemberChannelEvents(r.Context(), wallet, binding.OrgRootID, ownerVisible, cursor, limit)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to list member channel events")
return
}
out := make([]memberChannelEvent, 0, len(events))
for _, event := range events {
payload := map[string]any{}
if strings.TrimSpace(event.PayloadJSON) != "" {
_ = json.Unmarshal([]byte(event.PayloadJSON), &payload)
}
out = append(out, memberChannelEvent{
EventID: event.EventID,
Class: event.Class,
CreatedAt: event.CreatedAt.Format(time.RFC3339Nano),
Title: event.Title,
Body: event.Body,
DedupeKey: event.DedupeKey,
RequiresAck: event.RequiresAck,
PolicyHash: event.PolicyHash,
VisibilityScope: event.VisibilityScope,
Payload: payload,
})
}
writeJSON(w, http.StatusOK, memberChannelEventsResponse{
Wallet: wallet,
DeviceID: binding.DeviceID,
OrgRootID: binding.OrgRootID,
PrincipalID: binding.PrincipalID,
MembershipStatus: strings.ToLower(strings.TrimSpace(rec.MembershipStatus)),
IdentityAssurance: normalizeAssuranceLevel(rec.IdentityAssurance),
Events: out,
NextCursor: nextCursor,
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (a *app) handleMemberChannelEventAck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
const prefix = "/member/channel/events/"
path := strings.TrimPrefix(r.URL.Path, prefix)
if path == r.URL.Path || !strings.HasSuffix(path, "/ack") {
writeErrorCode(w, http.StatusNotFound, "not_found", "route not found")
return
}
eventID := strings.TrimSuffix(path, "/ack")
eventID = strings.TrimSpace(strings.TrimSuffix(eventID, "/"))
if eventID == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "event_id required")
return
}
var req memberChannelEventAckRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
deviceID := strings.TrimSpace(req.DeviceID)
if deviceID == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required")
return
}
ackAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.AcknowledgedAt))
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "acknowledged_at must be RFC3339")
return
}
event, err := a.store.getMemberChannelEventByID(r.Context(), eventID)
if err != nil {
writeErrorCode(w, http.StatusNotFound, "event_not_found", "event not found")
return
}
binding, err := a.store.getMemberChannelBinding(r.Context(), wallet, deviceID)
if err != nil || !strings.EqualFold(event.Wallet, wallet) || !strings.EqualFold(event.OrgRootID, binding.OrgRootID) {
writeErrorCode(w, http.StatusForbidden, "channel_binding_missing", "channel binding missing")
return
}
if strings.EqualFold(event.VisibilityScope, "owner_admin") && !strings.EqualFold(strings.TrimSpace(binding.PrincipalRole), "org_root_owner") {
writeErrorCode(w, http.StatusForbidden, "owner_role_required", "contact_your_org_admin")
return
}
effectiveAck, err := a.store.putMemberChannelEventAck(r.Context(), eventID, wallet, deviceID, ackAt.UTC())
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist acknowledgement")
return
}
writeJSON(w, http.StatusOK, memberChannelEventAckResponse{
Status: "acknowledged",
EventID: eventID,
AcknowledgedAt: effectiveAck.Format(time.RFC3339Nano),
})
}
func (a *app) handleMemberChannelSupportTicket(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req memberChannelSupportTicketRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
req.OrgRootID = strings.TrimSpace(req.OrgRootID)
req.PrincipalID = strings.TrimSpace(req.PrincipalID)
req.Category = strings.TrimSpace(req.Category)
req.Summary = strings.TrimSpace(req.Summary)
if req.OrgRootID == "" || req.PrincipalID == "" || req.Category == "" || req.Summary == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "org_root_id, principal_id, category, summary required")
return
}
rec, err := a.store.getDesignationByAddress(r.Context(), wallet)
if err != nil || !strings.EqualFold(strings.TrimSpace(rec.MembershipStatus), "active") {
writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership_inactive")
return
}
if !isOnrampAttested(rec.IdentityAssurance) {
writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "owner support actions require onramp_attested identity assurance")
return
}
role := ""
if principal, principalErr := a.store.getGovernancePrincipal(r.Context(), wallet); principalErr == nil &&
strings.EqualFold(strings.TrimSpace(principal.OrgRootID), req.OrgRootID) &&
strings.EqualFold(strings.TrimSpace(principal.PrincipalID), req.PrincipalID) {
role = strings.ToLower(strings.TrimSpace(principal.PrincipalRole))
}
if role == "" {
if binding, bindingErr := a.store.getMemberChannelBindingByPrincipal(r.Context(), wallet, req.OrgRootID, req.PrincipalID); bindingErr == nil {
role = strings.ToLower(strings.TrimSpace(binding.PrincipalRole))
}
}
if role != "org_root_owner" {
writeErrorCode(w, http.StatusForbidden, "owner_role_required", "contact_your_org_admin")
return
}
contextJSON := "{}"
if len(req.Context) > 0 {
raw, marshalErr := json.Marshal(req.Context)
if marshalErr == nil {
contextJSON = string(raw)
}
}
idRaw, err := randomHex(12)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "ticket_generation_failed", "failed to generate ticket id")
return
}
now := time.Now().UTC()
ticket := memberChannelSupportTicketRecord{
TicketID: "st_" + idRaw,
Wallet: wallet,
OrgRootID: req.OrgRootID,
PrincipalID: req.PrincipalID,
Category: req.Category,
Summary: req.Summary,
ContextJSON: contextJSON,
Status: "accepted",
CreatedAt: now,
}
if err := a.store.putMemberChannelSupportTicket(r.Context(), ticket); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist support ticket")
return
}
writeJSON(w, http.StatusOK, memberChannelSupportTicketResponse{
Status: "accepted",
TicketID: ticket.TicketID,
CreatedAt: now.Format(time.RFC3339Nano),
})
}
func (a *app) seedMemberChannelEvents(ctx context.Context, binding memberChannelBindingRecord) error {
membershipPayload := map[string]any{
"status": "active",
}
rawMembershipPayload, _ := json.Marshal(membershipPayload)
_ = a.store.putMemberChannelEvent(ctx, memberChannelEventRecord{
Wallet: binding.Wallet,
OrgRootID: binding.OrgRootID,
PrincipalID: binding.PrincipalID,
Class: "membership_policy",
CreatedAt: time.Now().UTC(),
Title: "Membership active",
Body: "Your EDUT membership is active. Governance install is available when you are ready.",
DedupeKey: "membership_policy:active",
RequiresAck: true,
PolicyHash: a.cfg.GovernancePolicyHash,
PayloadJSON: string(rawMembershipPayload),
VisibilityScope: "member",
})
if strings.EqualFold(strings.TrimSpace(binding.PrincipalRole), "org_root_owner") {
updatePayload := map[string]any{
"version": a.cfg.GovernanceRuntimeVersion,
"channel": a.cfg.GovernanceRolloutChannel,
}
rawUpdatePayload, _ := json.Marshal(updatePayload)
_ = a.store.putMemberChannelEvent(ctx, memberChannelEventRecord{
Wallet: binding.Wallet,
OrgRootID: binding.OrgRootID,
PrincipalID: binding.PrincipalID,
Class: "platform_update",
CreatedAt: time.Now().UTC(),
Title: "Governance runtime available",
Body: "A deterministic governance runtime is available for this organization boundary.",
DedupeKey: "platform_update:" + a.cfg.GovernanceRuntimeVersion,
RequiresAck: true,
PolicyHash: a.cfg.GovernancePolicyHash,
PayloadJSON: string(rawUpdatePayload),
VisibilityScope: "owner_admin",
})
}
return nil
}
func (a *app) resolveOrCreatePrincipal(ctx context.Context, wallet, orgRootID, principalID, principalRole string) (governancePrincipalRecord, error) {
principal, err := a.store.getGovernancePrincipal(ctx, wallet)
if err == nil {
return principal, nil
}
if !errors.Is(err, errNotFound) {
return governancePrincipalRecord{}, err
}
orgRootID = strings.TrimSpace(orgRootID)
principalID = strings.TrimSpace(principalID)
principalRole = strings.TrimSpace(strings.ToLower(principalRole))
if orgRootID == "" {
orgRootID = "org_" + wallet[2:10]
}
if principalID == "" {
principalID = "principal_" + wallet[2:10]
}
if principalRole == "" {
principalRole = "org_root_owner"
}
now := time.Now().UTC()
leaseExpires := now.Add(a.cfg.LeaseTTL)
principal = governancePrincipalRecord{
Wallet: wallet,
OrgRootID: orgRootID,
PrincipalID: principalID,
PrincipalRole: principalRole,
EntitlementID: "",
EntitlementStatus: "inactive",
AccessClass: "connected",
AvailabilityState: "active",
LeaseExpiresAt: &leaseExpires,
UpdatedAt: now,
}
if err := a.store.putGovernancePrincipal(ctx, principal); err != nil {
return governancePrincipalRecord{}, err
}
return principal, nil
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeErrorCode(w, status, "request_failed", message)
}
func writeErrorCode(w http.ResponseWriter, status int, code string, message string) {
correlationID := "req_unknown"
if rid, err := randomHex(8); err == nil {
correlationID = "req_" + rid
}
writeJSON(w, status, map[string]string{
"error": message,
"code": strings.TrimSpace(strings.ToLower(code)),
"correlation_id": correlationID,
})
}
func randomHex(byteLen int) (string, error) {
buf := make([]byte, byteLen)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func newDesignationCode() (code string, displayToken string, err error) {
// 13-digit deterministic shape: unix millis + random suffix.
now := time.Now().UTC().UnixMilli()
suffix, err := randomInt(1000, 9999)
if err != nil {
return "", "", err
}
raw := fmt.Sprintf("%d%d", now, suffix)
if len(raw) > 13 {
raw = raw[len(raw)-13:]
}
if len(raw) < 13 {
raw = strings.Repeat("0", 13-len(raw)) + raw
}
if !reDigits.MatchString(raw) {
return "", "", fmt.Errorf("invalid designation code")
}
return raw, buildDisplayToken(raw), nil
}
func buildDisplayToken(code string) string {
if len(code) < 13 {
return code
}
return code[0:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:]
}
func randomInt(min int, max int) (int, error) {
if min >= max {
return min, nil
}
span := big.NewInt(int64(max - min + 1))
n, err := rand.Int(rand.Reader, span)
if err != nil {
return 0, err
}
return min + int(n.Int64()), nil
}
func isTxHash(value string) bool {
value = strings.TrimSpace(strings.ToLower(value))
if !strings.HasPrefix(value, "0x") || len(value) != 66 {
return false
}
_, err := strconv.ParseUint(value[2:18], 16, 64)
return err == nil
}
func logConfig(cfg Config) {
log.Printf("secret api listening on %s chain_id=%d contract=%s currency=%s amount_atomic=%s", cfg.ListenAddr, cfg.ChainID, cfg.MembershipContract, cfg.MintCurrency, cfg.MintAmountAtomic)
if !cfg.RequireWalletSession {
log.Printf("warning: wallet session enforcement is disabled (SECRET_API_REQUIRE_WALLET_SESSION=false)")
}
}