1392 lines
49 KiB
Go
1392 lines
49 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/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",
|
|
}
|
|
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
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, walletVerifyResponse{
|
|
Status: "signature_verified",
|
|
DesignationCode: rec.Code,
|
|
DisplayToken: rec.DisplayToken,
|
|
VerifiedAt: 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 !strings.EqualFold(quote.Address, address) || !strings.EqualFold(quote.DesignationCode, req.DesignationCode) || quote.ChainID != req.ChainID {
|
|
writeError(w, http.StatusBadRequest, "quote context mismatch")
|
|
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 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()
|
|
rec.MembershipStatus = "active"
|
|
rec.MembershipTxHash = strings.ToLower(req.TxHash)
|
|
rec.ActivatedAt = &now
|
|
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),
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
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 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
|
|
}
|
|
|
|
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 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
|
|
}
|
|
|
|
membershipStatus := "none"
|
|
if rec, err := a.store.getDesignationByAddress(r.Context(), wallet); err == nil {
|
|
membershipStatus = strings.ToLower(strings.TrimSpace(rec.MembershipStatus))
|
|
if membershipStatus == "" {
|
|
membershipStatus = "none"
|
|
}
|
|
}
|
|
|
|
resp := governanceInstallStatusResponse{
|
|
Wallet: wallet,
|
|
MembershipStatus: membershipStatus,
|
|
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
|
|
}
|
|
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
|
|
}
|
|
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 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
|
|
}
|
|
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
|
|
}
|
|
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,
|
|
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
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|