1961 lines
71 KiB
Go
1961 lines
71 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
|
|
dependencies *dependencyEdges
|
|
}
|
|
|
|
func newApp(cfg Config, st *store) *app {
|
|
if st != nil {
|
|
st.configureMemberChannelEventThrottle(cfg.MemberEventBurstLimit, cfg.MemberEventBurstWindow)
|
|
}
|
|
return &app{cfg: cfg, store: st, dependencies: newDependencyEdges(cfg)}
|
|
}
|
|
|
|
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("/secret/id/quote", a.withCORS(a.handleMembershipQuote))
|
|
mux.HandleFunc("/secret/id/confirm", a.withCORS(a.handleMembershipConfirm))
|
|
mux.HandleFunc("/secret/id/status", a.withCORS(a.handleMembershipStatus))
|
|
mux.HandleFunc("/secret/setup/health", a.withCORS(a.handleSetupHealth))
|
|
mux.HandleFunc("/secret/id/mint-payload", a.withCORS(a.handleIDMintPayload))
|
|
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]any{
|
|
"status": "ok",
|
|
"dependencies": map[string]dependencyEdgeSnapshot{
|
|
dependencyEdgeChain: a.evaluateDependency(dependencyEdgeChain),
|
|
dependencyEdgeTLS: a.evaluateDependency(dependencyEdgeTLS),
|
|
dependencyEdgeDNS: a.evaluateDependency(dependencyEdgeDNS),
|
|
dependencyEdgeOnramp: a.evaluateDependency(dependencyEdgeOnramp),
|
|
dependencyEdgeCloud: a.evaluateDependency(dependencyEdgeCloud),
|
|
dependencyEdgeModel: a.evaluateDependency(dependencyEdgeModel),
|
|
},
|
|
})
|
|
}
|
|
|
|
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.issueWalletSessionForRequest(r.Context(), r, 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.issueWalletSessionForRequest(r.Context(), r, 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
|
|
}
|
|
paymentPath := normalizePaymentPath(req.PaymentPath)
|
|
if paymentPath == "" {
|
|
writeErrorCode(w, http.StatusBadRequest, "invalid_payment_path", "payment_path must be crypto_direct or fiat_onramp")
|
|
return
|
|
}
|
|
if paymentPath == paymentPathFiatOnramp && a.dependencyDegraded(dependencyEdgeOnramp) {
|
|
writeErrorCode(w, http.StatusServiceUnavailable, "dependency.onramp_unavailable", "card/on-ramp checkout path is temporarily unavailable")
|
|
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, defaultMarketplaceMerchantID, 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,
|
|
RegulatoryProfileID: a.cfg.RegulatoryProfileID,
|
|
Currency: quote.Currency,
|
|
AmountAtomic: quote.AmountAtomic,
|
|
Decimals: quote.Decimals,
|
|
CostEnvelope: newQuoteCostEnvelope(quote.Currency, quote.Decimals, quote.AmountAtomic),
|
|
Deadline: quote.ExpiresAt.Format(time.RFC3339Nano),
|
|
ContractAddress: quote.ContractAddress,
|
|
Method: quote.Method,
|
|
Calldata: quote.Calldata,
|
|
Value: quote.ValueHex,
|
|
OwnerWallet: address,
|
|
PayerWallet: payerAddress,
|
|
PaymentPath: paymentPath,
|
|
SponsorshipMode: sponsorshipMode,
|
|
SponsorOrgRoot: strings.TrimSpace(req.SponsorOrgRoot),
|
|
Tx: tx,
|
|
})
|
|
}
|
|
|
|
func (a *app) handleIDMintPayload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
var req idMintPayloadRequest
|
|
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
|
|
}
|
|
calldata, err := encodeMintMembershipCalldata(address)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to encode mint calldata")
|
|
return
|
|
}
|
|
contract := strings.ToLower(strings.TrimSpace(a.cfg.MembershipContract))
|
|
tx := map[string]any{
|
|
"from": address,
|
|
"to": contract,
|
|
"data": calldata,
|
|
"value": "0x0",
|
|
}
|
|
writeJSON(w, http.StatusOK, idMintPayloadResponse{
|
|
Status: "id_mint_payload_ready",
|
|
ChainID: a.cfg.ChainID,
|
|
RegulatoryProfileID: a.cfg.RegulatoryProfileID,
|
|
ContractAddress: contract,
|
|
Method: "mintMembership(address)",
|
|
Calldata: calldata,
|
|
Value: "0x0",
|
|
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
|
|
}
|
|
if code, message := a.chainMutationDependencyBlock(); code != "" {
|
|
writeErrorCode(w, http.StatusServiceUnavailable, code, message)
|
|
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 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",
|
|
EDUTIDStatus: "active",
|
|
DesignationCode: rec.Code,
|
|
DisplayToken: rec.DisplayToken,
|
|
RegulatoryProfileID: a.cfg.RegulatoryProfileID,
|
|
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",
|
|
EDUTIDStatus: "none",
|
|
RegulatoryProfileID: a.cfg.RegulatoryProfileID,
|
|
})
|
|
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,
|
|
EDUTIDStatus: status,
|
|
Wallet: rec.Address,
|
|
DesignationCode: rec.Code,
|
|
RegulatoryProfileID: a.cfg.RegulatoryProfileID,
|
|
IdentityAssurance: normalizeAssuranceLevel(rec.IdentityAssurance),
|
|
IdentityAttestedBy: strings.TrimSpace(rec.IdentityAttestedBy),
|
|
IdentityAttestationID: strings.TrimSpace(rec.IdentityAttestationID),
|
|
})
|
|
}
|
|
|
|
func (a *app) handleSetupHealth(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
|
|
}
|
|
|
|
resp, err := a.computeSetupHealth(r.Context(), wallet, time.Now().UTC())
|
|
if err != nil {
|
|
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve setup health")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (a *app) computeSetupHealth(ctx context.Context, wallet string, now time.Time) (setupHealthResponse, error) {
|
|
resp := setupHealthResponse{
|
|
Wallet: wallet,
|
|
ChainID: a.cfg.ChainID,
|
|
EDUTIDStatus: "none",
|
|
MembershipStatus: "none",
|
|
IdentityAssurance: assuranceNone,
|
|
ReadyForCheckout: false,
|
|
ReadyForAdmin: false,
|
|
Checks: make([]setupHealthCheck, 0, 8),
|
|
NextSteps: make([]string, 0, 4),
|
|
ServerTime: now.Format(time.RFC3339Nano),
|
|
}
|
|
|
|
addCheck := func(name, status, reason, nextStep string) {
|
|
resp.Checks = append(resp.Checks, setupHealthCheck{
|
|
Name: strings.TrimSpace(name),
|
|
Status: strings.TrimSpace(status),
|
|
Reason: strings.TrimSpace(reason),
|
|
NextStep: strings.TrimSpace(nextStep),
|
|
})
|
|
if strings.TrimSpace(status) != "pass" && strings.TrimSpace(nextStep) != "" {
|
|
resp.NextSteps = appendUniqueString(resp.NextSteps, strings.TrimSpace(nextStep))
|
|
}
|
|
}
|
|
|
|
rec, recErr := a.store.getDesignationByAddress(ctx, wallet)
|
|
if recErr != nil {
|
|
if errors.Is(recErr, errNotFound) {
|
|
addCheck("membership_active", "fail", "membership_inactive", "Activate EDUT ID for this wallet, then retry.")
|
|
addCheck("identity_assurance_attested", "fail", "identity_assurance_insufficient", "Complete on-ramp attestation on this wallet, then retry the admin action.")
|
|
return resp, nil
|
|
}
|
|
return setupHealthResponse{}, recErr
|
|
}
|
|
|
|
resp.MembershipStatus = strings.ToLower(strings.TrimSpace(rec.MembershipStatus))
|
|
if resp.MembershipStatus == "" {
|
|
resp.MembershipStatus = "none"
|
|
}
|
|
resp.EDUTIDStatus = resp.MembershipStatus
|
|
resp.IdentityAssurance = normalizeAssuranceLevel(rec.IdentityAssurance)
|
|
|
|
if resp.MembershipStatus == "active" {
|
|
resp.ReadyForCheckout = true
|
|
addCheck("membership_active", "pass", "", "")
|
|
} else {
|
|
addCheck("membership_active", "fail", "membership_inactive", "Activate EDUT ID for this wallet, then retry.")
|
|
}
|
|
|
|
if isOnrampAttested(resp.IdentityAssurance) {
|
|
addCheck("identity_assurance_attested", "pass", "", "")
|
|
} else {
|
|
addCheck("identity_assurance_attested", "fail", "identity_assurance_insufficient", "Complete on-ramp attestation on this wallet, then retry the admin action.")
|
|
}
|
|
|
|
principal, principalErr := a.store.getGovernancePrincipal(ctx, wallet)
|
|
if principalErr != nil {
|
|
if errors.Is(principalErr, errNotFound) {
|
|
addCheck("governance_principal_present", "fail", "principal_missing", "Install governance for this wallet to initialize principal state.")
|
|
return resp, nil
|
|
}
|
|
return setupHealthResponse{}, principalErr
|
|
}
|
|
|
|
resp.PrincipalPresent = true
|
|
resp.PrincipalRole = strings.TrimSpace(principal.PrincipalRole)
|
|
resp.EntitlementStatus = strings.ToLower(strings.TrimSpace(principal.EntitlementStatus))
|
|
resp.AvailabilityState = strings.ToLower(strings.TrimSpace(principal.AvailabilityState))
|
|
addCheck("governance_principal_present", "pass", "", "")
|
|
|
|
if strings.EqualFold(strings.TrimSpace(principal.PrincipalRole), "org_root_owner") {
|
|
addCheck("principal_role_owner", "pass", "", "")
|
|
} else {
|
|
addCheck("principal_role_owner", "fail", "role_insufficient", "Retry with an org_root_owner principal for this organization boundary.")
|
|
}
|
|
if resp.EntitlementStatus == "active" {
|
|
addCheck("governance_entitlement_active", "pass", "", "")
|
|
} else {
|
|
addCheck("governance_entitlement_active", "fail", "entitlement_inactive", "Activate the required entitlement for this wallet/org boundary, then retry.")
|
|
}
|
|
if resp.AvailabilityState != "parked" {
|
|
addCheck("availability_not_parked", "pass", "", "")
|
|
} else {
|
|
addCheck("availability_not_parked", "fail", "availability_parked", "Restore active availability for this org boundary, then retry admin operations.")
|
|
}
|
|
|
|
resp.ReadyForAdmin = resp.ReadyForCheckout &&
|
|
isOnrampAttested(resp.IdentityAssurance) &&
|
|
strings.EqualFold(strings.TrimSpace(principal.PrincipalRole), "org_root_owner") &&
|
|
resp.EntitlementStatus == "active" &&
|
|
resp.AvailabilityState != "parked"
|
|
return resp, nil
|
|
}
|
|
|
|
func (a *app) enforceSetupHealthForCheckout(w http.ResponseWriter, ctx context.Context, wallet string, allowMembershipBootstrap bool) bool {
|
|
resp, err := a.computeSetupHealth(ctx, wallet, time.Now().UTC())
|
|
if err != nil {
|
|
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve setup health")
|
|
return false
|
|
}
|
|
if resp.ReadyForCheckout || allowMembershipBootstrap {
|
|
return true
|
|
}
|
|
writeErrorCode(w, http.StatusForbidden, "setup_incomplete", "wallet setup health checks failed")
|
|
return false
|
|
}
|
|
|
|
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 !a.enforceAdminAssurance(w, rec, "governance_install") {
|
|
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,
|
|
EDUTIDStatus: membershipStatus,
|
|
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" && !isOnrampAttested(resp.IdentityAssurance) {
|
|
resp.ActivationStatus = "blocked"
|
|
if strings.TrimSpace(resp.Reason) == "" {
|
|
resp.Reason = "identity_assurance_insufficient"
|
|
}
|
|
}
|
|
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
|
|
}
|
|
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 !a.enforceAdminAssurance(w, rec, "governance_admin") {
|
|
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.EqualFold(strings.TrimSpace(principal.PrincipalRole), "org_root_owner") {
|
|
writeErrorCode(w, http.StatusForbidden, "role_insufficient", "governance admin controls require org_root_owner")
|
|
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
|
|
}
|
|
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 !a.enforceAdminAssurance(w, rec, "governance_admin") {
|
|
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.EqualFold(strings.TrimSpace(principal.PrincipalRole), "org_root_owner") {
|
|
writeErrorCode(w, http.StatusForbidden, "role_insufficient", "governance admin controls require org_root_owner")
|
|
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, defaultMarketplaceMerchantID, 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
|
|
}
|
|
if a.dependencyDegraded(dependencyEdgeCloud) {
|
|
writeErrorCode(w, http.StatusServiceUnavailable, "dependency.cloud_unavailable", "cloud push/channel edge unavailable; retry when cloud health is restored")
|
|
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))
|
|
digestActive := false
|
|
digestSuppressedCount := 0
|
|
trustedEventCount := 0
|
|
reviewEventCount := 0
|
|
for _, event := range events {
|
|
payload := map[string]any{}
|
|
if strings.TrimSpace(event.PayloadJSON) != "" {
|
|
_ = json.Unmarshal([]byte(event.PayloadJSON), &payload)
|
|
}
|
|
trustPosture, reviewLevel := memberChannelEventTrust(event, payload)
|
|
if strings.EqualFold(strings.TrimSpace(reviewLevel), "review") {
|
|
reviewEventCount++
|
|
} else {
|
|
trustedEventCount++
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(event.Class), "channel_digest") {
|
|
digestActive = true
|
|
digestSuppressedCount += payloadCountValue(payload["suppressed_count"])
|
|
}
|
|
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,
|
|
TrustPosture: trustPosture,
|
|
ReviewLevel: reviewLevel,
|
|
VisibilityScope: event.VisibilityScope,
|
|
Payload: payload,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, memberChannelEventsResponse{
|
|
Wallet: wallet,
|
|
DeviceID: binding.DeviceID,
|
|
OrgRootID: binding.OrgRootID,
|
|
PrincipalID: binding.PrincipalID,
|
|
EDUTIDStatus: strings.ToLower(strings.TrimSpace(rec.MembershipStatus)),
|
|
MembershipStatus: strings.ToLower(strings.TrimSpace(rec.MembershipStatus)),
|
|
IdentityAssurance: normalizeAssuranceLevel(rec.IdentityAssurance),
|
|
DigestActive: digestActive,
|
|
DigestSuppressed: digestSuppressedCount,
|
|
TrustedEvents: trustedEventCount,
|
|
ReviewEvents: reviewEventCount,
|
|
Events: out,
|
|
NextCursor: nextCursor,
|
|
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func memberChannelEventTrust(event memberChannelEventRecord, payload map[string]any) (string, string) {
|
|
class := strings.ToLower(strings.TrimSpace(event.Class))
|
|
if class == "channel_digest" {
|
|
return "digest_aggregated", "review"
|
|
}
|
|
if strings.TrimSpace(event.PolicyHash) != "" {
|
|
return "policy_verified", "trusted"
|
|
}
|
|
if strings.EqualFold(payloadStringValue(payload["verification_status"]), "verified") {
|
|
return "source_verified", "trusted"
|
|
}
|
|
return "unverified", "review"
|
|
}
|
|
|
|
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
|
|
}
|
|
if a.dependencyDegraded(dependencyEdgeCloud) {
|
|
writeErrorCode(w, http.StatusServiceUnavailable, "dependency.cloud_unavailable", "cloud support edge unavailable; retry when cloud health is restored")
|
|
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 !a.enforceAdminAssurance(w, rec, "owner_support") {
|
|
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 (a *app) chainMutationDependencyBlock() (string, string) {
|
|
if a.dependencyDegraded(dependencyEdgeDNS) {
|
|
return "dependency.dns_unavailable", "DNS resolution unavailable for chain verification; retry when DNS health is restored"
|
|
}
|
|
if a.dependencyDegraded(dependencyEdgeTLS) {
|
|
return "dependency.tls_unavailable", "TLS chain edge unavailable; retry when certificate/TLS health is restored"
|
|
}
|
|
if a.dependencyDegraded(dependencyEdgeChain) {
|
|
return "dependency.chain_unavailable", "chain verification unavailable; retry when network health is restored"
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func (a *app) enforceAdminAssurance(w http.ResponseWriter, rec designationRecord, operation string) bool {
|
|
if isOnrampAttested(rec.IdentityAssurance) {
|
|
return true
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(operation)) {
|
|
case "governance_install":
|
|
writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "onramp_attested assurance required for governance install")
|
|
case "governance_admin":
|
|
writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "onramp_attested assurance required for governance admin controls")
|
|
case "owner_support":
|
|
writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "onramp_attested assurance required for owner support")
|
|
default:
|
|
writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "onramp_attested assurance required")
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|
|
normalizedCode := strings.TrimSpace(strings.ToLower(code))
|
|
canonicalCode := canonicalErrorCode(normalizedCode)
|
|
writeJSON(w, status, map[string]string{
|
|
"error": message,
|
|
"code": normalizedCode,
|
|
"canonical_code": canonicalCode,
|
|
"next_step": errorNextStepForCode(normalizedCode),
|
|
"correlation_id": correlationID,
|
|
})
|
|
}
|
|
|
|
func errorNextStepForCode(code string) string {
|
|
code = strings.TrimSpace(strings.ToLower(code))
|
|
switch {
|
|
case code == "wallet_session_required", code == "wallet_session_invalid", code == "wallet_session_expired", code == "wallet_session_revoked", code == "wallet_session_mismatch", code == "wallet_session_context_mismatch":
|
|
return "Verify wallet signature to refresh the wallet session, then retry the request."
|
|
case code == "membership_inactive", code == "membership_required", code == "edut_id_inactive", code == "edut_id_required":
|
|
return "Activate EDUT ID for this wallet, then retry."
|
|
case code == "identity_assurance_insufficient":
|
|
return "Complete on-ramp attestation on this wallet, then retry the admin action."
|
|
case code == "role_insufficient", code == "owner_role_required":
|
|
return "Retry with an org_root_owner principal for this organization boundary."
|
|
case code == "approval_required":
|
|
return "Resubmit with approval_token and approval_actor from an authorized approver."
|
|
case code == "quote_expired":
|
|
return "Request a new checkout quote and retry confirmation."
|
|
case code == "tx_hash_replay":
|
|
return "Submit a unique on-chain transaction hash for this confirmation."
|
|
case code == "entitlement_inactive", code == "prerequisite_required", code == "sovereign_entitlement_required":
|
|
return "Activate the required entitlement for this wallet/org boundary, then retry."
|
|
case code == "setup_incomplete":
|
|
return "Call /secret/setup/health for this wallet and complete the listed next steps before retrying."
|
|
case code == "entitlement_contract_unconfigured":
|
|
return "Configure SECRET_API_ENTITLEMENT_CONTRACT on the server and request a new quote."
|
|
case strings.HasPrefix(code, "dependency."):
|
|
return "Retry after dependency health returns to healthy status."
|
|
case code == "context_mismatch", code == "boundary_mismatch":
|
|
return "Retry with wallet, org_root_id, principal_id, and quote/install context values that match the original request."
|
|
default:
|
|
return "Review the error code and request payload, correct the request context, and retry."
|
|
}
|
|
}
|
|
|
|
func canonicalErrorCode(code string) string {
|
|
code = strings.TrimSpace(strings.ToLower(code))
|
|
switch code {
|
|
case "membership_inactive":
|
|
return "edut_id_inactive"
|
|
case "membership_required":
|
|
return "edut_id_required"
|
|
case "membership_verification_failed":
|
|
return "edut_id_verification_failed"
|
|
default:
|
|
return code
|
|
}
|
|
}
|
|
|
|
func appendUniqueString(existing []string, value string) []string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return existing
|
|
}
|
|
for _, item := range existing {
|
|
if strings.EqualFold(strings.TrimSpace(item), value) {
|
|
return existing
|
|
}
|
|
}
|
|
return append(existing, value)
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|