api: add dependency-edge degraded modes and conformance tests
Some checks are pending
check / secretapi (push) Waiting to run

This commit is contained in:
Edut LLC 2026-02-20 15:42:54 -08:00
parent cbcf027d97
commit 496d8cf97a
20 changed files with 3015 additions and 61 deletions

View File

@ -17,5 +17,9 @@ jobs:
run: |
cd backend/secretapi
go test ./...
- name: Docs/runtime parity gates
run: |
cd backend/secretapi
go test ./... -run 'Test(OpenAPIPathParityWithRuntimeRoutes|SecretAPIEnvParityWithDocsAndTemplate)' -count=1
- name: Validate deployment artifacts
run: ./scripts/check_deployment_artifacts.sh

View File

@ -1,7 +1,18 @@
SECRET_API_LISTEN_ADDR=:8080
SECRET_API_DB_PATH=./secret.db
SECRET_API_ALLOWED_ORIGIN=https://edut.ai
SECRET_API_DEPLOYMENT_CLASS=development
SECRET_API_REGULATORY_PROFILE_ID=us_general_2026
SECRET_API_MEMBER_POLL_INTERVAL_SECONDS=30
SECRET_API_MEMBER_CHANNEL_EVENT_BURST_LIMIT=25
SECRET_API_MEMBER_CHANNEL_EVENT_BURST_WINDOW_SECONDS=3600
SECRET_API_DEPENDENCY_RECOVERY_STABILITY_SECONDS=60
SECRET_API_DEPENDENCY_CHAIN_STATE=auto
SECRET_API_DEPENDENCY_TLS_STATE=auto
SECRET_API_DEPENDENCY_DNS_STATE=auto
SECRET_API_DEPENDENCY_ONRAMP_STATE=auto
SECRET_API_DEPENDENCY_CLOUD_STATE=auto
SECRET_API_DEPENDENCY_MODEL_STATE=auto
SECRET_API_CHAIN_ID=84532
SECRET_API_CHAIN_RPC_URL=
@ -15,9 +26,10 @@ SECRET_API_DOMAIN_NAME=EDUT Designation
SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000
SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000
SECRET_API_ENTITLEMENT_CONTRACT=0x0000000000000000000000000000000000000000
SECRET_API_MINT_CURRENCY=USDC
SECRET_API_MINT_AMOUNT_ATOMIC=100000000
SECRET_API_MINT_DECIMALS=6
SECRET_API_MINT_CURRENCY=ETH
SECRET_API_MINT_AMOUNT_ATOMIC=0
SECRET_API_MINT_DECIMALS=18
SECRET_API_FINANCIAL_APPROVAL_THRESHOLD_ATOMIC=0
SECRET_API_INSTALL_TOKEN_TTL_SECONDS=900
SECRET_API_LEASE_TTL_SECONDS=3600

View File

@ -21,12 +21,16 @@ var (
)
type app struct {
cfg Config
store *store
cfg Config
store *store
dependencies *dependencyEdges
}
func newApp(cfg Config, st *store) *app {
return &app{cfg: cfg, store: st}
if st != nil {
st.configureMemberChannelEventThrottle(cfg.MemberEventBurstLimit, cfg.MemberEventBurstWindow)
}
return &app{cfg: cfg, store: st, dependencies: newDependencyEdges(cfg)}
}
func (a *app) routes() http.Handler {
@ -42,6 +46,8 @@ func (a *app) routes() http.Handler {
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))
@ -85,7 +91,17 @@ func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
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) {
@ -217,7 +233,7 @@ func (a *app) handleWalletVerify(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "failed to store verification status")
return
}
session, err := a.issueWalletSession(r.Context(), rec.Address, rec.Code)
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
@ -272,7 +288,7 @@ func (a *app) handleWalletSessionRefresh(w http.ResponseWriter, r *http.Request)
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to revoke old wallet session")
return
}
newSession, err := a.issueWalletSession(r.Context(), wallet, oldSession.DesignationCode)
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
@ -345,6 +361,15 @@ func (a *app) handleMembershipQuote(w http.ResponseWriter, r *http.Request) {
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)
@ -479,12 +504,56 @@ func (a *app) handleMembershipQuote(w http.ResponseWriter, r *http.Request) {
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")
@ -508,6 +577,10 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
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 {
@ -539,11 +612,6 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve membership tx hash reuse")
return
}
if a.cfg.RequireOnchainTxVerify && strings.TrimSpace(a.cfg.ChainRPCURL) == "" {
writeErrorCode(w, http.StatusServiceUnavailable, "chain_verification_unavailable", "chain rpc required for membership confirmation")
return
}
if strings.TrimSpace(quote.PayerAddress) == "" {
quote.PayerAddress = quote.Address
}
@ -663,6 +731,134 @@ func (a *app) handleMembershipStatus(w http.ResponseWriter, r *http.Request) {
})
}
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,
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.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")
@ -699,8 +895,7 @@ func (a *app) handleGovernanceInstallToken(w http.ResponseWriter, r *http.Reques
writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership not active")
return
}
if !isOnrampAttested(rec.IdentityAssurance) {
writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "workspace admin operations require onramp_attested identity assurance")
if !a.enforceAdminAssurance(w, rec, "governance_install") {
return
}
@ -949,6 +1144,12 @@ func (a *app) handleGovernanceInstallStatus(w http.ResponseWriter, r *http.Reque
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"
}
@ -973,6 +1174,14 @@ func (a *app) handleGovernanceLeaseHeartbeat(w http.ResponseWriter, r *http.Requ
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")
@ -982,6 +1191,10 @@ func (a *app) handleGovernanceLeaseHeartbeat(w http.ResponseWriter, r *http.Requ
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
@ -1019,6 +1232,14 @@ func (a *app) handleGovernanceOfflineRenew(w http.ResponseWriter, r *http.Reques
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")
@ -1028,6 +1249,10 @@ func (a *app) handleGovernanceOfflineRenew(w http.ResponseWriter, r *http.Reques
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
@ -1067,6 +1292,10 @@ func (a *app) handleMemberChannelDeviceRegister(w http.ResponseWriter, r *http.R
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")
@ -1234,11 +1463,25 @@ func (a *app) handleMemberChannelEvents(w http.ResponseWriter, r *http.Request)
}
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,
@ -1248,6 +1491,8 @@ func (a *app) handleMemberChannelEvents(w http.ResponseWriter, r *http.Request)
DedupeKey: event.DedupeKey,
RequiresAck: event.RequiresAck,
PolicyHash: event.PolicyHash,
TrustPosture: trustPosture,
ReviewLevel: reviewLevel,
VisibilityScope: event.VisibilityScope,
Payload: payload,
})
@ -1259,12 +1504,30 @@ func (a *app) handleMemberChannelEvents(w http.ResponseWriter, r *http.Request)
PrincipalID: binding.PrincipalID,
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")
@ -1337,6 +1600,10 @@ func (a *app) handleMemberChannelSupportTicket(w http.ResponseWriter, r *http.Re
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")
@ -1364,8 +1631,7 @@ func (a *app) handleMemberChannelSupportTicket(w http.ResponseWriter, r *http.Re
writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership_inactive")
return
}
if !isOnrampAttested(rec.IdentityAssurance) {
writeErrorCode(w, http.StatusForbidden, "identity_assurance_insufficient", "owner support actions require onramp_attested identity assurance")
if !a.enforceAdminAssurance(w, rec, "owner_support") {
return
}
@ -1505,6 +1771,36 @@ func (a *app) resolveOrCreatePrincipal(ctx context.Context, wallet, orgRootID, p
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)
@ -1520,13 +1816,60 @@ func writeErrorCode(w http.ResponseWriter, status int, code string, message stri
if rid, err := randomHex(8); err == nil {
correlationID = "req_" + rid
}
normalizedCode := strings.TrimSpace(strings.ToLower(code))
writeJSON(w, status, map[string]string{
"error": message,
"code": strings.TrimSpace(strings.ToLower(code)),
"code": normalizedCode,
"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":
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 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 {

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,15 @@ type Config struct {
AllowedOrigin string
DeploymentClass string
RegulatoryProfileID string
MemberEventBurstLimit int
MemberEventBurstWindow time.Duration
DependencyRecoveryWindow time.Duration
DependencyChainState string
DependencyTLSState string
DependencyDNSState string
DependencyOnrampState string
DependencyCloudState string
DependencyModelState string
MemberPollIntervalSec int
IntentTTL time.Duration
QuoteTTL time.Duration
@ -31,6 +40,7 @@ type Config struct {
MintCurrency string
MintAmountAtomic string
MintDecimals int
FinancialApprovalThresholdAtomic string
ChainRPCURL string
RequireOnchainTxVerify bool
GovernanceRuntimeVersion string
@ -49,6 +59,15 @@ func loadConfig() Config {
AllowedOrigin: env("SECRET_API_ALLOWED_ORIGIN", "https://edut.ai"),
DeploymentClass: normalizeDeploymentClass(env("SECRET_API_DEPLOYMENT_CLASS", "development")),
RegulatoryProfileID: normalizeRegulatoryProfileID(env("SECRET_API_REGULATORY_PROFILE_ID", defaultRegulatoryProfileID)),
MemberEventBurstLimit: envInt("SECRET_API_MEMBER_CHANNEL_EVENT_BURST_LIMIT", 25),
MemberEventBurstWindow: time.Duration(envInt("SECRET_API_MEMBER_CHANNEL_EVENT_BURST_WINDOW_SECONDS", 3600)) * time.Second,
DependencyRecoveryWindow: time.Duration(envInt("SECRET_API_DEPENDENCY_RECOVERY_STABILITY_SECONDS", 60)) * time.Second,
DependencyChainState: normalizeDependencyStateMode(env("SECRET_API_DEPENDENCY_CHAIN_STATE", dependencyStateAuto)),
DependencyTLSState: normalizeDependencyStateMode(env("SECRET_API_DEPENDENCY_TLS_STATE", dependencyStateAuto)),
DependencyDNSState: normalizeDependencyStateMode(env("SECRET_API_DEPENDENCY_DNS_STATE", dependencyStateAuto)),
DependencyOnrampState: normalizeDependencyStateMode(env("SECRET_API_DEPENDENCY_ONRAMP_STATE", dependencyStateAuto)),
DependencyCloudState: normalizeDependencyStateMode(env("SECRET_API_DEPENDENCY_CLOUD_STATE", dependencyStateAuto)),
DependencyModelState: normalizeDependencyStateMode(env("SECRET_API_DEPENDENCY_MODEL_STATE", dependencyStateAuto)),
MemberPollIntervalSec: envInt("SECRET_API_MEMBER_POLL_INTERVAL_SECONDS", 30),
IntentTTL: time.Duration(envInt("SECRET_API_INTENT_TTL_SECONDS", 900)) * time.Second,
QuoteTTL: time.Duration(envInt("SECRET_API_QUOTE_TTL_SECONDS", 900)) * time.Second,
@ -62,9 +81,10 @@ func loadConfig() Config {
VerifyingContract: strings.ToLower(env("SECRET_API_VERIFYING_CONTRACT", "0x0000000000000000000000000000000000000000")),
MembershipContract: strings.ToLower(env("SECRET_API_MEMBERSHIP_CONTRACT", "0x0000000000000000000000000000000000000000")),
EntitlementContract: strings.ToLower(env("SECRET_API_ENTITLEMENT_CONTRACT", "0x0000000000000000000000000000000000000000")),
MintCurrency: strings.ToUpper(env("SECRET_API_MINT_CURRENCY", "USDC")),
MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "100000000"),
MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 6),
MintCurrency: strings.ToUpper(env("SECRET_API_MINT_CURRENCY", "ETH")),
MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "0"),
MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 18),
FinancialApprovalThresholdAtomic: env("SECRET_API_FINANCIAL_APPROVAL_THRESHOLD_ATOMIC", "0"),
ChainRPCURL: env("SECRET_API_CHAIN_RPC_URL", ""),
RequireOnchainTxVerify: loadRequireOnchainTxVerify(),
GovernanceRuntimeVersion: env("SECRET_API_GOV_RUNTIME_VERSION", "0.1.0"),
@ -81,6 +101,36 @@ func (c Config) Validate() error {
if c.ChainID <= 0 {
return fmt.Errorf("SECRET_API_CHAIN_ID must be positive")
}
if c.MemberEventBurstLimit < 0 {
return fmt.Errorf("SECRET_API_MEMBER_CHANNEL_EVENT_BURST_LIMIT must be non-negative")
}
if c.MemberEventBurstWindow < 0 {
return fmt.Errorf("SECRET_API_MEMBER_CHANNEL_EVENT_BURST_WINDOW_SECONDS must be non-negative")
}
if c.MemberEventBurstLimit > 0 && c.MemberEventBurstWindow <= 0 {
return fmt.Errorf("SECRET_API_MEMBER_CHANNEL_EVENT_BURST_WINDOW_SECONDS must be positive when burst limit is enabled")
}
if c.DependencyRecoveryWindow < 0 {
return fmt.Errorf("SECRET_API_DEPENDENCY_RECOVERY_STABILITY_SECONDS must be non-negative")
}
if !isValidDependencyStateMode(c.DependencyChainState) {
return fmt.Errorf("SECRET_API_DEPENDENCY_CHAIN_STATE must be auto, healthy, or degraded")
}
if !isValidDependencyStateMode(c.DependencyTLSState) {
return fmt.Errorf("SECRET_API_DEPENDENCY_TLS_STATE must be auto, healthy, or degraded")
}
if !isValidDependencyStateMode(c.DependencyDNSState) {
return fmt.Errorf("SECRET_API_DEPENDENCY_DNS_STATE must be auto, healthy, or degraded")
}
if !isValidDependencyStateMode(c.DependencyOnrampState) {
return fmt.Errorf("SECRET_API_DEPENDENCY_ONRAMP_STATE must be auto, healthy, or degraded")
}
if !isValidDependencyStateMode(c.DependencyCloudState) {
return fmt.Errorf("SECRET_API_DEPENDENCY_CLOUD_STATE must be auto, healthy, or degraded")
}
if !isValidDependencyStateMode(c.DependencyModelState) {
return fmt.Errorf("SECRET_API_DEPENDENCY_MODEL_STATE must be auto, healthy, or degraded")
}
if !isKnownRegulatoryProfileID(c.RegulatoryProfileID) {
return fmt.Errorf("SECRET_API_REGULATORY_PROFILE_ID must be %s or %s", regulatoryProfileUSGeneral2026, regulatoryProfileEUAIBaseline2026)
}
@ -99,8 +149,13 @@ func (c Config) Validate() error {
}
amountRaw := strings.TrimSpace(c.MintAmountAtomic)
amount, ok := new(big.Int).SetString(amountRaw, 10)
if !ok || amount.Sign() <= 0 {
return fmt.Errorf("SECRET_API_MINT_AMOUNT_ATOMIC must be a positive base-10 integer")
if !ok || amount.Sign() < 0 {
return fmt.Errorf("SECRET_API_MINT_AMOUNT_ATOMIC must be a non-negative base-10 integer")
}
approvalThresholdRaw := strings.TrimSpace(c.FinancialApprovalThresholdAtomic)
approvalThreshold, ok := new(big.Int).SetString(approvalThresholdRaw, 10)
if !ok || approvalThreshold.Sign() < 0 {
return fmt.Errorf("SECRET_API_FINANCIAL_APPROVAL_THRESHOLD_ATOMIC must be a non-negative base-10 integer")
}
if c.WalletSessionTTL <= 0 {
return fmt.Errorf("SECRET_API_WALLET_SESSION_TTL_SECONDS must be positive")

View File

@ -0,0 +1,114 @@
package main
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
var envVarPattern = regexp.MustCompile(`SECRET_API_[A-Z0-9_]+`)
func TestSecretAPIEnvParityWithDocsAndTemplate(t *testing.T) {
repoRoot := findSecretAPIRepoRoot(t)
configPath := filepath.Join(repoRoot, "config.go")
readmePath := filepath.Join(repoRoot, "README.md")
envExamplePath := filepath.Join(repoRoot, ".env.example")
configVars := extractEnvVarsFromFile(t, configPath)
readmeVars := extractEnvVarsFromFile(t, readmePath)
envExampleVars := extractEnvVarsFromEnvExample(t, envExamplePath)
assertSubset(t, "README.md", configVars, readmeVars)
assertSubset(t, ".env.example", configVars, envExampleVars)
assertNoUnexpected(t, ".env.example", configVars, envExampleVars)
}
func extractEnvVarsFromFile(t *testing.T, path string) map[string]struct{} {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
matches := envVarPattern.FindAllString(string(raw), -1)
set := make(map[string]struct{}, len(matches))
for _, m := range matches {
set[m] = struct{}{}
}
return set
}
func extractEnvVarsFromEnvExample(t *testing.T, path string) map[string]struct{} {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
set := make(map[string]struct{})
for _, line := range strings.Split(string(raw), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if idx := strings.Index(trimmed, "="); idx > 0 {
name := strings.TrimSpace(trimmed[:idx])
if envVarPattern.MatchString(name) {
set[name] = struct{}{}
}
}
}
return set
}
func assertSubset(t *testing.T, surface string, required map[string]struct{}, provided map[string]struct{}) {
t.Helper()
missing := make([]string, 0)
for key := range required {
if _, ok := provided[key]; !ok {
missing = append(missing, key)
}
}
if len(missing) == 0 {
return
}
sort.Strings(missing)
t.Fatalf("%s missing runtime env vars: %s", surface, strings.Join(missing, ", "))
}
func assertNoUnexpected(t *testing.T, surface string, required map[string]struct{}, provided map[string]struct{}) {
t.Helper()
extra := make([]string, 0)
for key := range provided {
if _, ok := required[key]; !ok {
extra = append(extra, key)
}
}
if len(extra) == 0 {
return
}
sort.Strings(extra)
t.Fatalf("%s has undocumented/unknown env vars not present in runtime config: %s", surface, strings.Join(extra, ", "))
}
func findSecretAPIRepoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
cur := wd
for {
if _, err := os.Stat(filepath.Join(cur, "config.go")); err == nil {
if _, err := os.Stat(filepath.Join(cur, "README.md")); err == nil {
return cur
}
}
next := filepath.Dir(cur)
if next == cur {
t.Fatalf("could not locate secretapi repo root from %s", wd)
}
cur = next
}
}

View File

@ -1,6 +1,9 @@
package main
import "testing"
import (
"testing"
"time"
)
func loadConfigIsolated(t *testing.T) Config {
t.Helper()
@ -72,6 +75,38 @@ func TestConfigValidateRejectsInvalidMintAmount(t *testing.T) {
}
}
func TestConfigValidateAllowsZeroMintAmount(t *testing.T) {
cfg := loadConfigIsolated(t)
cfg.MintAmountAtomic = "0"
if err := cfg.Validate(); err != nil {
t.Fatalf("expected zero mint amount to validate for gas-only flow, got %v", err)
}
}
func TestConfigValidateRejectsNegativeMintAmount(t *testing.T) {
cfg := loadConfigIsolated(t)
cfg.MintAmountAtomic = "-1"
if err := cfg.Validate(); err == nil {
t.Fatalf("expected negative mint amount validation failure")
}
}
func TestConfigValidateRejectsInvalidFinancialApprovalThreshold(t *testing.T) {
cfg := loadConfigIsolated(t)
cfg.FinancialApprovalThresholdAtomic = "not-a-number"
if err := cfg.Validate(); err == nil {
t.Fatalf("expected financial approval threshold validation failure")
}
}
func TestConfigValidateRejectsNegativeFinancialApprovalThreshold(t *testing.T) {
cfg := loadConfigIsolated(t)
cfg.FinancialApprovalThresholdAtomic = "-1"
if err := cfg.Validate(); err == nil {
t.Fatalf("expected negative financial approval threshold validation failure")
}
}
func TestLoadConfigDefaultsWalletSessionRequired(t *testing.T) {
t.Setenv("SECRET_API_REQUIRE_WALLET_SESSION", "")
cfg := loadConfigIsolated(t)
@ -133,3 +168,56 @@ func TestConfigValidateRejectsUnknownRegulatoryProfile(t *testing.T) {
t.Fatalf("expected regulatory profile validation failure")
}
}
func TestConfigValidateRejectsNegativeDependencyRecoveryWindow(t *testing.T) {
cfg := loadConfigIsolated(t)
cfg.DependencyRecoveryWindow = -1 * time.Second
if err := cfg.Validate(); err == nil {
t.Fatalf("expected dependency recovery window validation failure")
}
}
func TestConfigValidateRejectsInvalidDependencyModes(t *testing.T) {
cfg := loadConfigIsolated(t)
cfg.DependencyChainState = "invalid"
if err := cfg.Validate(); err == nil {
t.Fatalf("expected chain dependency mode validation failure")
}
cfg = loadConfigIsolated(t)
cfg.DependencyTLSState = "invalid"
if err := cfg.Validate(); err == nil {
t.Fatalf("expected tls dependency mode validation failure")
}
cfg = loadConfigIsolated(t)
cfg.DependencyDNSState = "invalid"
if err := cfg.Validate(); err == nil {
t.Fatalf("expected dns dependency mode validation failure")
}
}
func TestConfigValidateRejectsNegativeMemberEventBurstLimit(t *testing.T) {
cfg := loadConfigIsolated(t)
cfg.MemberEventBurstLimit = -1
if err := cfg.Validate(); err == nil {
t.Fatalf("expected member event burst limit validation failure")
}
}
func TestConfigValidateRejectsNegativeMemberEventBurstWindow(t *testing.T) {
cfg := loadConfigIsolated(t)
cfg.MemberEventBurstWindow = -1 * time.Second
if err := cfg.Validate(); err == nil {
t.Fatalf("expected member event burst window validation failure")
}
}
func TestConfigValidateRejectsZeroMemberEventBurstWindowWhenLimitEnabled(t *testing.T) {
cfg := loadConfigIsolated(t)
cfg.MemberEventBurstLimit = 10
cfg.MemberEventBurstWindow = 0
if err := cfg.Validate(); err == nil {
t.Fatalf("expected member event burst window validation failure when limit enabled")
}
}

View File

@ -0,0 +1,277 @@
package main
import (
"log"
"net"
neturl "net/url"
"strings"
"sync"
"time"
)
const (
dependencyEdgeChain = "chain"
dependencyEdgeTLS = "tls"
dependencyEdgeDNS = "dns"
dependencyEdgeOnramp = "onramp"
dependencyEdgeCloud = "cloud"
dependencyEdgeModel = "model"
dependencyStateAuto = "auto"
dependencyStateHealthy = "healthy"
dependencyStateDegraded = "degraded"
paymentPathCryptoDirect = "crypto_direct"
paymentPathFiatOnramp = "fiat_onramp"
)
type dependencyEdgeSnapshot struct {
State string `json:"state"`
Mode string `json:"mode"`
Reason string `json:"reason,omitempty"`
RecoveryWindowSec int64 `json:"recovery_window_seconds"`
DegradedSince string `json:"degraded_since,omitempty"`
RecoveryHealthySince string `json:"recovery_healthy_since,omitempty"`
LastTransitionAt string `json:"last_transition_at,omitempty"`
}
type dependencyEdgeState struct {
Degraded bool
DegradedSince time.Time
RecoveryHealthySince time.Time
LastTransitionAt time.Time
LastReason string
}
type dependencyEdges struct {
mu sync.Mutex
now func() time.Time
recoveryDelay time.Duration
edges map[string]dependencyEdgeState
}
func newDependencyEdges(cfg Config) *dependencyEdges {
return &dependencyEdges{
now: func() time.Time { return time.Now().UTC() },
recoveryDelay: cfg.DependencyRecoveryWindow,
edges: map[string]dependencyEdgeState{
dependencyEdgeChain: {},
dependencyEdgeTLS: {},
dependencyEdgeDNS: {},
dependencyEdgeOnramp: {},
dependencyEdgeCloud: {},
dependencyEdgeModel: {},
},
}
}
func normalizeDependencyStateMode(raw string) string {
v := strings.ToLower(strings.TrimSpace(raw))
switch v {
case dependencyStateHealthy:
return dependencyStateHealthy
case dependencyStateDegraded:
return dependencyStateDegraded
default:
return dependencyStateAuto
}
}
func isValidDependencyStateMode(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", dependencyStateAuto, dependencyStateHealthy, dependencyStateDegraded:
return true
default:
return false
}
}
func normalizePaymentPath(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", paymentPathCryptoDirect:
return paymentPathCryptoDirect
case paymentPathFiatOnramp:
return paymentPathFiatOnramp
default:
return ""
}
}
func (d *dependencyEdges) evaluate(edge string, mode string, probeHealthy bool, reason string) dependencyEdgeSnapshot {
d.mu.Lock()
defer d.mu.Unlock()
normalizedEdge := strings.ToLower(strings.TrimSpace(edge))
state := d.edges[normalizedEdge]
now := d.now().UTC()
mode = normalizeDependencyStateMode(mode)
reason = strings.TrimSpace(reason)
degradedTarget := false
switch mode {
case dependencyStateDegraded:
degradedTarget = true
if reason == "" {
reason = "forced_degraded"
}
case dependencyStateHealthy:
degradedTarget = false
if reason == "" {
reason = "forced_healthy"
}
default:
degradedTarget = !probeHealthy
if degradedTarget && reason == "" {
reason = "probe_unhealthy"
}
}
if degradedTarget {
if !state.Degraded {
state.Degraded = true
state.DegradedSince = now
state.RecoveryHealthySince = time.Time{}
state.LastTransitionAt = now
state.LastReason = reason
log.Printf("audit.dependency_edge_transition edge=%s from=healthy to=degraded at=%s reason=%s", normalizedEdge, now.Format(time.RFC3339Nano), reason)
} else if reason != "" {
state.LastReason = reason
}
d.edges[normalizedEdge] = state
return dependencySnapshot(mode, state, d.recoveryDelay)
}
if state.Degraded {
if state.RecoveryHealthySince.IsZero() {
state.RecoveryHealthySince = now
}
if d.recoveryDelay > 0 && now.Sub(state.RecoveryHealthySince) < d.recoveryDelay {
if reason == "" {
reason = "recovery_stability_window"
}
state.LastReason = reason
d.edges[normalizedEdge] = state
return dependencySnapshot(mode, state, d.recoveryDelay)
}
state.Degraded = false
state.DegradedSince = time.Time{}
state.RecoveryHealthySince = time.Time{}
state.LastTransitionAt = now
if reason == "" {
reason = "recovered"
}
state.LastReason = reason
log.Printf("audit.dependency_edge_transition edge=%s from=degraded to=healthy at=%s reason=%s", normalizedEdge, now.Format(time.RFC3339Nano), reason)
d.edges[normalizedEdge] = state
return dependencySnapshot(mode, state, d.recoveryDelay)
}
if reason != "" {
state.LastReason = reason
d.edges[normalizedEdge] = state
}
return dependencySnapshot(mode, state, d.recoveryDelay)
}
func dependencySnapshot(mode string, state dependencyEdgeState, window time.Duration) dependencyEdgeSnapshot {
snap := dependencyEdgeSnapshot{
State: dependencyStateHealthy,
Mode: mode,
RecoveryWindowSec: int64(window / time.Second),
Reason: strings.TrimSpace(state.LastReason),
}
if state.Degraded {
snap.State = dependencyStateDegraded
}
if !state.DegradedSince.IsZero() {
snap.DegradedSince = state.DegradedSince.UTC().Format(time.RFC3339Nano)
}
if !state.RecoveryHealthySince.IsZero() {
snap.RecoveryHealthySince = state.RecoveryHealthySince.UTC().Format(time.RFC3339Nano)
}
if !state.LastTransitionAt.IsZero() {
snap.LastTransitionAt = state.LastTransitionAt.UTC().Format(time.RFC3339Nano)
}
return snap
}
func (a *app) dependencyMode(edge string) string {
switch strings.ToLower(strings.TrimSpace(edge)) {
case dependencyEdgeChain:
return a.cfg.DependencyChainState
case dependencyEdgeTLS:
return a.cfg.DependencyTLSState
case dependencyEdgeDNS:
return a.cfg.DependencyDNSState
case dependencyEdgeOnramp:
return a.cfg.DependencyOnrampState
case dependencyEdgeCloud:
return a.cfg.DependencyCloudState
case dependencyEdgeModel:
return a.cfg.DependencyModelState
default:
return dependencyStateAuto
}
}
func (a *app) dependencyProbe(edge string) (bool, string) {
switch strings.ToLower(strings.TrimSpace(edge)) {
case dependencyEdgeChain:
if a.cfg.RequireOnchainTxVerify && strings.TrimSpace(a.cfg.ChainRPCURL) == "" {
return false, "chain_rpc_unconfigured"
}
return true, "ok"
case dependencyEdgeTLS:
if !a.cfg.RequireOnchainTxVerify {
return true, "ok"
}
rpcURL := strings.TrimSpace(a.cfg.ChainRPCURL)
if rpcURL == "" {
return true, "ok"
}
parsed, err := neturl.Parse(rpcURL)
if err != nil {
return false, "tls_rpc_url_invalid"
}
if !strings.EqualFold(strings.TrimSpace(parsed.Scheme), "https") {
return false, "tls_https_required"
}
return true, "ok"
case dependencyEdgeDNS:
if !a.cfg.RequireOnchainTxVerify {
return true, "ok"
}
rpcURL := strings.TrimSpace(a.cfg.ChainRPCURL)
if rpcURL == "" {
return true, "ok"
}
parsed, err := neturl.Parse(rpcURL)
if err != nil {
return false, "dns_rpc_url_invalid"
}
hostname := strings.TrimSpace(parsed.Hostname())
if hostname == "" {
return false, "dns_host_missing"
}
if net.ParseIP(hostname) != nil {
return true, "ok"
}
if !strings.Contains(hostname, ".") {
return false, "dns_host_unqualified"
}
return true, "ok"
case dependencyEdgeOnramp, dependencyEdgeCloud, dependencyEdgeModel:
return true, "ok"
default:
return true, "ok"
}
}
func (a *app) evaluateDependency(edge string) dependencyEdgeSnapshot {
probeHealthy, reason := a.dependencyProbe(edge)
return a.dependencies.evaluate(edge, a.dependencyMode(edge), probeHealthy, reason)
}
func (a *app) dependencyDegraded(edge string) bool {
return a.evaluateDependency(edge).State == dependencyStateDegraded
}

View File

@ -0,0 +1,114 @@
package main
import (
"testing"
"time"
)
func TestNormalizeDependencyStateMode(t *testing.T) {
cases := map[string]string{
"": dependencyStateAuto,
"auto": dependencyStateAuto,
"AUTO": dependencyStateAuto,
"healthy": dependencyStateHealthy,
"degraded": dependencyStateDegraded,
"unknown": dependencyStateAuto,
" healthy ": dependencyStateHealthy,
}
for input, want := range cases {
if got := normalizeDependencyStateMode(input); got != want {
t.Fatalf("normalizeDependencyStateMode(%q)=%q want=%q", input, got, want)
}
}
}
func TestNormalizePaymentPath(t *testing.T) {
cases := map[string]string{
"": paymentPathCryptoDirect,
"crypto_direct": paymentPathCryptoDirect,
"fiat_onramp": paymentPathFiatOnramp,
"unknown": "",
}
for input, want := range cases {
if got := normalizePaymentPath(input); got != want {
t.Fatalf("normalizePaymentPath(%q)=%q want=%q", input, got, want)
}
}
}
func TestDependencyEdgesRecoveryWindow(t *testing.T) {
edges := newDependencyEdges(Config{DependencyRecoveryWindow: 60 * time.Second})
now := time.Date(2026, time.February, 20, 9, 30, 0, 0, time.UTC)
edges.now = func() time.Time { return now }
degraded := edges.evaluate(dependencyEdgeOnramp, dependencyStateAuto, false, "probe_failed")
if degraded.State != dependencyStateDegraded {
t.Fatalf("expected degraded state, got %+v", degraded)
}
now = now.Add(10 * time.Second)
stillDegraded := edges.evaluate(dependencyEdgeOnramp, dependencyStateAuto, true, "probe_ok")
if stillDegraded.State != dependencyStateDegraded {
t.Fatalf("expected degraded during recovery window, got %+v", stillDegraded)
}
now = now.Add(2 * time.Minute)
recovered := edges.evaluate(dependencyEdgeOnramp, dependencyStateAuto, true, "probe_ok")
if recovered.State != dependencyStateHealthy {
t.Fatalf("expected healthy after recovery window, got %+v", recovered)
}
if recovered.LastTransitionAt == "" {
t.Fatalf("expected last transition timestamp in recovered snapshot")
}
}
func TestDependencyProbeTLSRequiresHTTPSWhenStrictVerificationEnabled(t *testing.T) {
a := &app{
cfg: Config{
RequireOnchainTxVerify: true,
ChainRPCURL: "http://rpc.base.invalid",
},
dependencies: newDependencyEdges(Config{}),
}
healthy, reason := a.dependencyProbe(dependencyEdgeTLS)
if healthy {
t.Fatalf("expected tls probe to fail for non-https rpc URL")
}
if reason != "tls_https_required" {
t.Fatalf("expected tls_https_required, got %q", reason)
}
}
func TestDependencyProbeDNSRejectsUnqualifiedHostWhenStrictVerificationEnabled(t *testing.T) {
a := &app{
cfg: Config{
RequireOnchainTxVerify: true,
ChainRPCURL: "https://localhost:8545",
},
dependencies: newDependencyEdges(Config{}),
}
healthy, reason := a.dependencyProbe(dependencyEdgeDNS)
if healthy {
t.Fatalf("expected dns probe to fail for unqualified hostname")
}
if reason != "dns_host_unqualified" {
t.Fatalf("expected dns_host_unqualified, got %q", reason)
}
}
func TestDependencyProbeDNSAcceptsIPHostWhenStrictVerificationEnabled(t *testing.T) {
a := &app{
cfg: Config{
RequireOnchainTxVerify: true,
ChainRPCURL: "https://127.0.0.1:8545",
},
dependencies: newDependencyEdges(Config{}),
}
healthy, reason := a.dependencyProbe(dependencyEdgeDNS)
if !healthy {
t.Fatalf("expected dns probe healthy for IP host, got reason=%q", reason)
}
if reason != "ok" {
t.Fatalf("expected ok reason, got %q", reason)
}
}

View File

@ -2,6 +2,8 @@ package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@ -14,9 +16,10 @@ import (
)
const (
marketplaceMembershipActivationAtomic = "100000000" // 100.00 USDC (6 decimals)
marketplaceStandardOfferAtomic = "1000000000" // 1000.00 USDC (6 decimals)
defaultMarketplaceMerchantID = "edut.firstparty"
marketplaceMembershipActivationAtomic = "100000000" // 100.00 USDC (6 decimals)
marketplaceStandardOfferAtomic = "1000000000" // 1000.00 USDC (6 decimals)
defaultMarketplaceMerchantID = "edut.firstparty"
marketplaceApprovalReasonFinancialThresholdExceeded = "financial_threshold_exceeded"
offerIDSoloCore = "edut.solo.core"
offerIDWorkspaceCore = "edut.workspace.core"
@ -289,6 +292,19 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
return
}
if strings.EqualFold(strings.TrimSpace(offer.OfferID), offerIDWorkspaceAI) && a.dependencyDegraded(dependencyEdgeModel) {
writeErrorCode(w, http.StatusServiceUnavailable, "dependency.model_unavailable", "AI provider currently unavailable for model-dependent activation")
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
}
payerWallet := wallet
if strings.TrimSpace(req.PayerWallet) != "" {
@ -456,6 +472,7 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
if strings.EqualFold(strings.TrimSpace(offer.OfferID), offerIDWorkspaceSovereign) {
accessClass = "sovereign"
}
approvalRequired, approvalReason := a.marketplaceApprovalRequirement(totalAmount.String())
quote := marketplaceQuoteRecord{
QuoteID: "cq_" + quoteIDRaw,
MerchantID: merchantID,
@ -471,6 +488,8 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
TotalAmountAtomic: totalAmount.String(),
Decimals: offer.Pricing.Decimals,
MembershipIncluded: membershipIncluded,
ApprovalRequired: approvalRequired,
ApprovalReason: approvalReason,
LineItemsJSON: string(lineItemsJSON),
PolicyHash: a.cfg.GovernancePolicyHash,
AccessClass: accessClass,
@ -504,9 +523,12 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
Decimals: quote.Decimals,
CostEnvelope: newQuoteCostEnvelope(quote.Currency, quote.Decimals, quote.TotalAmountAtomic),
MembershipActivationIncluded: quote.MembershipIncluded,
ApprovalRequired: quote.ApprovalRequired,
ApprovalReason: quote.ApprovalReason,
LineItems: lineItems,
PolicyHash: quote.PolicyHash,
ExpiresAt: quote.ExpiresAt.Format(time.RFC3339Nano),
PaymentPath: paymentPath,
Tx: map[string]any{
"from": quote.PayerWallet,
"to": quote.ExpectedTxTo,
@ -548,6 +570,10 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
writeErrorCode(w, http.StatusBadRequest, "unsupported_chain_id", fmt.Sprintf("unsupported chain_id: %d", req.ChainID))
return
}
if code, message := a.chainMutationDependencyBlock(); code != "" {
writeErrorCode(w, http.StatusServiceUnavailable, code, message)
return
}
if !isTxHash(req.TxHash) {
writeErrorCode(w, http.StatusBadRequest, "invalid_tx_hash", "invalid tx hash")
return
@ -591,6 +617,9 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "workspace_id mismatch")
return
}
if !a.enforceSetupHealthForCheckout(w, r.Context(), wallet, quote.MembershipIncluded) {
return
}
if existingQuoteID, lookupErr := a.store.getMarketplaceQuoteIDByConfirmedTxHash(r.Context(), req.TxHash); lookupErr == nil {
if !strings.EqualFold(existingQuoteID, quote.QuoteID) {
writeErrorCode(w, http.StatusConflict, "tx_hash_replay", "tx hash already used for a different checkout confirmation")
@ -617,6 +646,8 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
PrincipalRole: existing.PrincipalRole,
Wallet: existing.Wallet,
TxHash: existing.TxHash,
ApprovalTokenRef: quote.ApprovalTokenRef,
ApprovalActor: quote.ApprovalActor,
PolicyHash: existing.PolicyHash,
ActivatedAt: existing.IssuedAt.Format(time.RFC3339Nano),
AccessClass: existing.AccessClass,
@ -627,11 +658,16 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
writeErrorCode(w, http.StatusConflict, "quote_already_confirmed", "quote already confirmed")
return
}
if a.cfg.RequireOnchainTxVerify && strings.TrimSpace(a.cfg.ChainRPCURL) == "" {
writeErrorCode(w, http.StatusServiceUnavailable, "chain_verification_unavailable", "chain rpc required for checkout confirmation")
return
if quote.ApprovalRequired {
approvalToken := strings.TrimSpace(req.ApprovalToken)
approvalActor := strings.TrimSpace(req.ApprovalActor)
if approvalToken == "" || approvalActor == "" {
writeErrorCode(w, http.StatusForbidden, "approval_required", "approval_token and approval_actor required")
return
}
quote.ApprovalTokenRef = approvalTokenRef(approvalToken)
quote.ApprovalActor = approvalActor
}
expectedPayer := strings.TrimSpace(quote.PayerWallet)
if expectedPayer == "" {
expectedPayer = wallet
@ -721,6 +757,8 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
PrincipalRole: ent.PrincipalRole,
Wallet: ent.Wallet,
TxHash: ent.TxHash,
ApprovalTokenRef: quote.ApprovalTokenRef,
ApprovalActor: quote.ApprovalActor,
PolicyHash: ent.PolicyHash,
ActivatedAt: ent.IssuedAt.Format(time.RFC3339Nano),
AccessClass: ent.AccessClass,
@ -824,6 +862,38 @@ func (a *app) ensureMembershipActiveForWallet(ctx context.Context, wallet, txHas
return a.store.putDesignation(ctx, rec)
}
func (a *app) marketplaceApprovalRequirement(totalAmountAtomic string) (bool, string) {
threshold := a.financialApprovalThresholdAtomic()
if threshold.Sign() <= 0 {
return false, ""
}
total := new(big.Int)
if _, ok := total.SetString(strings.TrimSpace(totalAmountAtomic), 10); !ok {
return false, ""
}
if total.Cmp(threshold) > 0 {
return true, marketplaceApprovalReasonFinancialThresholdExceeded
}
return false, ""
}
func (a *app) financialApprovalThresholdAtomic() *big.Int {
threshold := new(big.Int)
raw := strings.TrimSpace(a.cfg.FinancialApprovalThresholdAtomic)
if raw == "" {
return threshold
}
if _, ok := threshold.SetString(raw, 10); !ok || threshold.Sign() < 0 {
return new(big.Int)
}
return threshold
}
func approvalTokenRef(token string) string {
digest := sha256.Sum256([]byte("edut.marketplace.approval.v1|" + strings.TrimSpace(token)))
return "sha256:" + hex.EncodeToString(digest[:])
}
func buildEntitlementID(chainID int64, wallet string) string {
token, _ := randomHex(4)
return fmt.Sprintf("ent:%d:%s:%s", chainID, wallet, token)

View File

@ -45,6 +45,7 @@ type marketplaceOffersResponse struct {
type marketplaceCheckoutQuoteRequest struct {
MerchantID string `json:"merchant_id,omitempty"`
Wallet string `json:"wallet"`
PaymentPath string `json:"payment_path,omitempty"`
PayerWallet string `json:"payer_wallet,omitempty"`
OfferID string `json:"offer_id"`
OrgRootID string `json:"org_root_id,omitempty"`
@ -82,9 +83,12 @@ type marketplaceCheckoutQuoteResponse struct {
Decimals int `json:"decimals"`
CostEnvelope quoteCostEnvelope `json:"cost_envelope"`
MembershipActivationIncluded bool `json:"membership_activation_included"`
ApprovalRequired bool `json:"approval_required"`
ApprovalReason string `json:"approval_reason,omitempty"`
LineItems []marketplaceQuoteLineItem `json:"line_items"`
PolicyHash string `json:"policy_hash"`
ExpiresAt string `json:"expires_at"`
PaymentPath string `json:"payment_path,omitempty"`
Tx map[string]any `json:"tx"`
AccessClass string `json:"access_class"`
AvailabilityState string `json:"availability_state"`
@ -100,6 +104,8 @@ type marketplaceCheckoutConfirmRequest struct {
PrincipalID string `json:"principal_id,omitempty"`
PrincipalRole string `json:"principal_role,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`
ApprovalToken string `json:"approval_token,omitempty"`
ApprovalActor string `json:"approval_actor,omitempty"`
TxHash string `json:"tx_hash"`
ChainID int64 `json:"chain_id"`
}
@ -115,6 +121,8 @@ type marketplaceCheckoutConfirmResponse struct {
PrincipalRole string `json:"principal_role,omitempty"`
Wallet string `json:"wallet"`
TxHash string `json:"tx_hash"`
ApprovalTokenRef string `json:"approval_token_ref,omitempty"`
ApprovalActor string `json:"approval_actor,omitempty"`
PolicyHash string `json:"policy_hash"`
ActivatedAt string `json:"activated_at"`
AccessClass string `json:"access_class"`
@ -154,6 +162,10 @@ type marketplaceQuoteRecord struct {
TotalAmountAtomic string
Decimals int
MembershipIncluded bool
ApprovalRequired bool
ApprovalReason string
ApprovalTokenRef string
ApprovalActor string
LineItemsJSON string
PolicyHash string
AccessClass string

View File

@ -62,11 +62,28 @@ type membershipQuoteRequest struct {
DesignationCode string `json:"designation_code"`
Address string `json:"address"`
ChainID int64 `json:"chain_id"`
PaymentPath string `json:"payment_path,omitempty"`
PayerWallet string `json:"payer_wallet,omitempty"`
PayerProof string `json:"payer_proof,omitempty"`
SponsorOrgRoot string `json:"sponsor_org_root_id,omitempty"`
}
type idMintPayloadRequest struct {
Address string `json:"address"`
ChainID int64 `json:"chain_id"`
}
type idMintPayloadResponse struct {
Status string `json:"status"`
ChainID int64 `json:"chain_id"`
RegulatoryProfileID string `json:"regulatory_profile_id"`
ContractAddress string `json:"contract_address"`
Method string `json:"method"`
Calldata string `json:"calldata"`
Value string `json:"value"`
Tx map[string]any `json:"tx"`
}
type membershipQuoteResponse struct {
QuoteID string `json:"quote_id"`
ChainID int64 `json:"chain_id"`
@ -82,6 +99,7 @@ type membershipQuoteResponse struct {
Value string `json:"value"`
OwnerWallet string `json:"owner_wallet,omitempty"`
PayerWallet string `json:"payer_wallet,omitempty"`
PaymentPath string `json:"payment_path,omitempty"`
SponsorshipMode string `json:"sponsorship_mode,omitempty"`
SponsorOrgRoot string `json:"sponsor_org_root_id,omitempty"`
Tx map[string]any `json:"tx"`
@ -120,6 +138,29 @@ type membershipStatusResponse struct {
IdentityAttestationID string `json:"identity_attestation_id,omitempty"`
}
type setupHealthCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
NextStep string `json:"next_step,omitempty"`
}
type setupHealthResponse struct {
Wallet string `json:"wallet"`
ChainID int64 `json:"chain_id"`
MembershipStatus string `json:"membership_status"`
IdentityAssurance string `json:"identity_assurance_level"`
PrincipalPresent bool `json:"principal_present"`
PrincipalRole string `json:"principal_role,omitempty"`
EntitlementStatus string `json:"entitlement_status,omitempty"`
AvailabilityState string `json:"availability_state,omitempty"`
ReadyForCheckout bool `json:"ready_for_checkout"`
ReadyForAdmin bool `json:"ready_for_admin"`
Checks []setupHealthCheck `json:"checks"`
NextSteps []string `json:"next_steps,omitempty"`
ServerTime string `json:"server_time"`
}
type designationRecord struct {
Code string
DisplayToken string
@ -167,6 +208,8 @@ type walletSessionRecord struct {
Wallet string
DesignationCode string
ChainID int64
BindingHash string
BindingSource string
IssuedAt time.Time
ExpiresAt time.Time
LastSeenAt *time.Time
@ -302,6 +345,10 @@ type memberChannelEventsResponse struct {
PrincipalID string `json:"principal_id"`
MembershipStatus string `json:"membership_status"`
IdentityAssurance string `json:"identity_assurance_level"`
DigestActive bool `json:"digest_active"`
DigestSuppressed int `json:"digest_suppressed_count"`
TrustedEvents int `json:"trusted_event_count"`
ReviewEvents int `json:"review_event_count"`
Events []memberChannelEvent `json:"events"`
NextCursor string `json:"next_cursor"`
ServerTime string `json:"server_time"`
@ -316,6 +363,8 @@ type memberChannelEvent struct {
DedupeKey string `json:"dedupe_key"`
RequiresAck bool `json:"requires_ack"`
PolicyHash string `json:"policy_hash"`
TrustPosture string `json:"trust_posture"`
ReviewLevel string `json:"review_level"`
VisibilityScope string `json:"visibility_scope"`
Payload map[string]any `json:"payload,omitempty"`
}

View File

@ -0,0 +1,119 @@
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
var handleFuncRoutePattern = regexp.MustCompile(`mux\.HandleFunc\("([^"]+)"`)
var openAPIPathPattern = regexp.MustCompile(`^\s{2}(/[^:]+):\s*$`)
func TestOpenAPIPathParityWithRuntimeRoutes(t *testing.T) {
repoRoot := findSecretAPIRepoRoot(t)
appPath := filepath.Join(repoRoot, "app.go")
docAPIPath := filepath.Clean(filepath.Join(repoRoot, "..", "..", "docs", "api"))
runtimeRoutes := extractRuntimeRoutes(t, appPath)
docRoutes := extractOpenAPIRoutes(t, docAPIPath)
ignoreRuntimeOnly := map[string]struct{}{
"/healthz": {},
}
for route := range runtimeRoutes {
if _, ignored := ignoreRuntimeOnly[route]; ignored {
continue
}
docRoute := runtimeRouteToDocRoute(route)
if _, ok := docRoutes[docRoute]; !ok {
t.Fatalf("runtime route %s missing from OpenAPI docs (expected path %s)", route, docRoute)
}
}
missingRuntime := make([]string, 0)
for docRoute := range docRoutes {
runtimeRoute := docRouteToRuntimeRoute(docRoute)
if _, ok := runtimeRoutes[runtimeRoute]; !ok {
missingRuntime = append(missingRuntime, fmt.Sprintf("%s (expected runtime route %s)", docRoute, runtimeRoute))
}
}
if len(missingRuntime) > 0 {
sort.Strings(missingRuntime)
t.Fatalf("documented OpenAPI routes missing runtime handlers:\n%s", strings.Join(missingRuntime, "\n"))
}
}
func extractRuntimeRoutes(t *testing.T, appPath string) map[string]struct{} {
t.Helper()
raw, err := os.ReadFile(appPath)
if err != nil {
t.Fatalf("read %s: %v", appPath, err)
}
set := make(map[string]struct{})
matches := handleFuncRoutePattern.FindAllStringSubmatch(string(raw), -1)
for _, m := range matches {
if len(m) >= 2 {
set[m[1]] = struct{}{}
}
}
if len(set) == 0 {
t.Fatalf("no runtime routes parsed from %s", appPath)
}
return set
}
func extractOpenAPIRoutes(t *testing.T, dir string) map[string]struct{} {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read docs api dir %s: %v", dir, err)
}
set := make(map[string]struct{})
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".openapi.yaml") {
continue
}
path := filepath.Join(dir, entry.Name())
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
for _, line := range strings.Split(string(raw), "\n") {
m := openAPIPathPattern.FindStringSubmatch(line)
if len(m) >= 2 {
set[m[1]] = struct{}{}
}
}
}
if len(set) == 0 {
t.Fatalf("no OpenAPI routes parsed from %s", dir)
}
return set
}
func runtimeRouteToDocRoute(route string) string {
switch route {
case "/marketplace/offers/":
return "/marketplace/offers/{offer_id}"
case "/member/channel/events/":
return "/member/channel/events/{event_id}/ack"
default:
return route
}
}
func docRouteToRuntimeRoute(route string) string {
switch route {
case "/marketplace/offers/{offer_id}":
return "/marketplace/offers/"
case "/member/channel/events/{event_id}/ack":
return "/member/channel/events/"
default:
return route
}
}

View File

@ -2,6 +2,8 @@ package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"time"
@ -10,9 +12,18 @@ import (
const (
sessionHeaderToken = "X-Edut-Session"
sessionHeaderExpiresAt = "X-Edut-Session-Expires-At"
sessionHeaderBinding = "X-Edut-Device-Binding"
)
func (a *app) issueWalletSession(ctx context.Context, wallet, designationCode string) (walletSessionRecord, error) {
return a.issueWalletSessionWithBinding(ctx, wallet, designationCode, walletSessionBinding{})
}
func (a *app) issueWalletSessionForRequest(ctx context.Context, r *http.Request, wallet, designationCode string) (walletSessionRecord, error) {
return a.issueWalletSessionWithBinding(ctx, wallet, designationCode, walletSessionBindingFromRequest(r))
}
func (a *app) issueWalletSessionWithBinding(ctx context.Context, wallet, designationCode string, binding walletSessionBinding) (walletSessionRecord, error) {
_, _ = a.store.deleteExpiredWalletSessions(ctx, time.Now().UTC())
token, err := randomHex(24)
if err != nil {
@ -24,6 +35,8 @@ func (a *app) issueWalletSession(ctx context.Context, wallet, designationCode st
Wallet: strings.ToLower(strings.TrimSpace(wallet)),
DesignationCode: strings.TrimSpace(designationCode),
ChainID: a.cfg.ChainID,
BindingHash: strings.TrimSpace(binding.Hash),
BindingSource: strings.TrimSpace(binding.Source),
IssuedAt: now,
ExpiresAt: now.Add(a.cfg.WalletSessionTTL),
LastSeenAt: &now,
@ -87,6 +100,13 @@ func (a *app) enforceWalletSession(w http.ResponseWriter, r *http.Request, walle
writeErrorCode(w, http.StatusForbidden, "wallet_session_mismatch", "wallet session does not match requested wallet")
return false
}
if strings.TrimSpace(rec.BindingHash) != "" {
binding := walletSessionBindingFromRequest(r)
if strings.TrimSpace(binding.Hash) == "" || !strings.EqualFold(strings.TrimSpace(rec.BindingHash), strings.TrimSpace(binding.Hash)) {
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_context_mismatch", "wallet session context mismatch")
return false
}
}
if err := a.store.touchWalletSession(r.Context(), rec.SessionToken, now); err != nil && err != errNotFound {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to update wallet session")
return false
@ -96,3 +116,28 @@ func (a *app) enforceWalletSession(w http.ResponseWriter, r *http.Request, walle
w.Header().Set(sessionHeaderExpiresAt, rec.ExpiresAt.UTC().Format(time.RFC3339Nano))
return true
}
type walletSessionBinding struct {
Hash string
Source string
}
func walletSessionBindingFromRequest(r *http.Request) walletSessionBinding {
if r == nil {
return walletSessionBinding{}
}
source := "device_binding"
value := strings.TrimSpace(r.Header.Get(sessionHeaderBinding))
if value == "" {
source = "user_agent"
value = strings.TrimSpace(r.UserAgent())
}
if value == "" {
return walletSessionBinding{}
}
digest := sha256.Sum256([]byte("edut.wallet_session.binding.v1|" + source + "|" + value))
return walletSessionBinding{
Hash: hex.EncodeToString(digest[:]),
Source: source,
}
}

View File

@ -3,6 +3,7 @@ package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
@ -16,7 +17,13 @@ var (
)
type store struct {
db *sql.DB
db *sql.DB
memberEventBurstLimit int
memberEventBurstWindow time.Duration
memberEventDigestClass string
memberEventDigestVisibility string
memberEventDigestTitle string
memberEventDigestBodyTemplate string
}
func openStore(path string) (*store, error) {
@ -25,7 +32,15 @@ func openStore(path string) (*store, error) {
return nil, err
}
db.SetMaxOpenConns(1)
s := &store{db: db}
s := &store{
db: db,
memberEventBurstLimit: 0,
memberEventBurstWindow: 0,
memberEventDigestClass: "channel_digest",
memberEventDigestVisibility: "member",
memberEventDigestTitle: "Update digest ready",
memberEventDigestBodyTemplate: "High activity detected. Open this digest for grouped updates.",
}
if err := s.migrate(context.Background()); err != nil {
_ = db.Close()
return nil, err
@ -37,6 +52,20 @@ func (s *store) close() error {
return s.db.Close()
}
func (s *store) configureMemberChannelEventThrottle(limit int, window time.Duration) {
if s == nil {
return
}
if limit < 0 {
limit = 0
}
if window < 0 {
window = 0
}
s.memberEventBurstLimit = limit
s.memberEventBurstWindow = window
}
func (s *store) migrate(ctx context.Context) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS designations (
@ -67,6 +96,8 @@ func (s *store) migrate(ctx context.Context) error {
wallet TEXT NOT NULL,
designation_code TEXT NOT NULL,
chain_id INTEGER NOT NULL,
binding_hash TEXT NOT NULL DEFAULT '',
binding_source TEXT NOT NULL DEFAULT '',
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT,
@ -112,6 +143,10 @@ func (s *store) migrate(ctx context.Context) error {
total_amount_atomic TEXT NOT NULL,
decimals INTEGER NOT NULL,
membership_included INTEGER NOT NULL DEFAULT 0,
approval_required INTEGER NOT NULL DEFAULT 0,
approval_reason TEXT,
approval_token_ref TEXT,
approval_actor TEXT,
line_items_json TEXT NOT NULL,
policy_hash TEXT NOT NULL,
access_class TEXT NOT NULL,
@ -287,9 +322,27 @@ func (s *store) migrate(ctx context.Context) error {
if err := s.ensureColumn(ctx, "marketplace_quotes", "merchant_id", "TEXT NOT NULL DEFAULT 'edut.firstparty'"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "marketplace_quotes", "approval_required", "INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "marketplace_quotes", "approval_reason", "TEXT"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "marketplace_quotes", "approval_token_ref", "TEXT"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "marketplace_quotes", "approval_actor", "TEXT"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "marketplace_entitlements", "merchant_id", "TEXT NOT NULL DEFAULT 'edut.firstparty'"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "wallet_sessions", "binding_hash", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := s.ensureColumn(ctx, "wallet_sessions", "binding_source", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_marketplace_entitlements_wallet_merchant_offer_state ON marketplace_entitlements(wallet, merchant_id, offer_id, state);`); err != nil {
return err
}
@ -415,23 +468,25 @@ func scanDesignation(row interface{ Scan(dest ...any) error }) (designationRecor
func (s *store) putWalletSession(ctx context.Context, rec walletSessionRecord) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO wallet_sessions (
session_token, wallet, designation_code, chain_id, issued_at, expires_at, last_seen_at, revoked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
session_token, wallet, designation_code, chain_id, binding_hash, binding_source, issued_at, expires_at, last_seen_at, revoked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_token) DO UPDATE SET
wallet=excluded.wallet,
designation_code=excluded.designation_code,
chain_id=excluded.chain_id,
binding_hash=excluded.binding_hash,
binding_source=excluded.binding_source,
issued_at=excluded.issued_at,
expires_at=excluded.expires_at,
last_seen_at=excluded.last_seen_at,
revoked_at=excluded.revoked_at
`, strings.TrimSpace(rec.SessionToken), strings.ToLower(strings.TrimSpace(rec.Wallet)), strings.TrimSpace(rec.DesignationCode), rec.ChainID, rec.IssuedAt.UTC().Format(time.RFC3339Nano), rec.ExpiresAt.UTC().Format(time.RFC3339Nano), formatNullableTime(rec.LastSeenAt), formatNullableTime(rec.RevokedAt))
`, strings.TrimSpace(rec.SessionToken), strings.ToLower(strings.TrimSpace(rec.Wallet)), strings.TrimSpace(rec.DesignationCode), rec.ChainID, strings.TrimSpace(rec.BindingHash), strings.TrimSpace(rec.BindingSource), rec.IssuedAt.UTC().Format(time.RFC3339Nano), rec.ExpiresAt.UTC().Format(time.RFC3339Nano), formatNullableTime(rec.LastSeenAt), formatNullableTime(rec.RevokedAt))
return err
}
func (s *store) getWalletSession(ctx context.Context, token string) (walletSessionRecord, error) {
row := s.db.QueryRowContext(ctx, `
SELECT session_token, wallet, designation_code, chain_id, issued_at, expires_at, last_seen_at, revoked_at
SELECT session_token, wallet, designation_code, chain_id, binding_hash, binding_source, issued_at, expires_at, last_seen_at, revoked_at
FROM wallet_sessions
WHERE session_token = ?
`, strings.TrimSpace(token))
@ -440,7 +495,7 @@ func (s *store) getWalletSession(ctx context.Context, token string) (walletSessi
var expiresAt sql.NullString
var lastSeenAt sql.NullString
var revokedAt sql.NullString
err := row.Scan(&rec.SessionToken, &rec.Wallet, &rec.DesignationCode, &rec.ChainID, &issuedAt, &expiresAt, &lastSeenAt, &revokedAt)
err := row.Scan(&rec.SessionToken, &rec.Wallet, &rec.DesignationCode, &rec.ChainID, &rec.BindingHash, &rec.BindingSource, &issuedAt, &expiresAt, &lastSeenAt, &revokedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return walletSessionRecord{}, errNotFound
@ -600,9 +655,9 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
_, err := s.db.ExecContext(ctx, `
INSERT INTO marketplace_quotes (
quote_id, merchant_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
currency, amount_atomic, total_amount_atomic, decimals, membership_included, approval_required, approval_reason, approval_token_ref, approval_actor, line_items_json,
policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(quote_id) DO UPDATE SET
merchant_id=excluded.merchant_id,
wallet=excluded.wallet,
@ -617,6 +672,10 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
total_amount_atomic=excluded.total_amount_atomic,
decimals=excluded.decimals,
membership_included=excluded.membership_included,
approval_required=excluded.approval_required,
approval_reason=excluded.approval_reason,
approval_token_ref=excluded.approval_token_ref,
approval_actor=excluded.approval_actor,
line_items_json=excluded.line_items_json,
policy_hash=excluded.policy_hash,
access_class=excluded.access_class,
@ -642,6 +701,10 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
strings.TrimSpace(quote.TotalAmountAtomic),
quote.Decimals,
boolToInt(quote.MembershipIncluded),
boolToInt(quote.ApprovalRequired),
nullableString(strings.TrimSpace(quote.ApprovalReason)),
nullableString(strings.TrimSpace(quote.ApprovalTokenRef)),
nullableString(strings.TrimSpace(quote.ApprovalActor)),
quote.LineItemsJSON,
strings.TrimSpace(quote.PolicyHash),
strings.ToLower(strings.TrimSpace(quote.AccessClass)),
@ -660,16 +723,17 @@ func (s *store) putMarketplaceQuote(ctx context.Context, quote marketplaceQuoteR
func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (marketplaceQuoteRecord, error) {
row := s.db.QueryRowContext(ctx, `
SELECT quote_id, merchant_id, wallet, payer_wallet, offer_id, org_root_id, principal_id, principal_role, workspace_id,
currency, amount_atomic, total_amount_atomic, decimals, membership_included, line_items_json,
currency, amount_atomic, total_amount_atomic, decimals, membership_included, approval_required, approval_reason, approval_token_ref, approval_actor, line_items_json,
policy_hash, access_class, availability_state, expected_tx_to, expected_tx_data, expected_tx_value_hex, created_at, expires_at, confirmed_at, confirmed_tx_hash
FROM marketplace_quotes
WHERE quote_id = ?
`, strings.TrimSpace(quoteID))
var rec marketplaceQuoteRecord
var merchantID, payerWallet, orgRootID, principalID, principalRole, workspaceID sql.NullString
var approvalReason, approvalTokenRef, approvalActor sql.NullString
var expectedTxTo, expectedTxData, expectedTxValueHex sql.NullString
var createdAt, expiresAt, confirmedAt, confirmedTxHash sql.NullString
var membershipIncluded int
var membershipIncluded, approvalRequired int
err := row.Scan(
&rec.QuoteID,
&merchantID,
@ -685,6 +749,10 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
&rec.TotalAmountAtomic,
&rec.Decimals,
&membershipIncluded,
&approvalRequired,
&approvalReason,
&approvalTokenRef,
&approvalActor,
&rec.LineItemsJSON,
&rec.PolicyHash,
&rec.AccessClass,
@ -713,6 +781,10 @@ func (s *store) getMarketplaceQuote(ctx context.Context, quoteID string) (market
rec.ExpectedTxData = expectedTxData.String
rec.ExpectedTxValueHex = expectedTxValueHex.String
rec.MembershipIncluded = membershipIncluded == 1
rec.ApprovalRequired = approvalRequired == 1
rec.ApprovalReason = strings.TrimSpace(approvalReason.String)
rec.ApprovalTokenRef = strings.TrimSpace(approvalTokenRef.String)
rec.ApprovalActor = strings.TrimSpace(approvalActor.String)
rec.CreatedAt = parseRFC3339Nullable(createdAt)
rec.ExpiresAt = parseRFC3339Nullable(expiresAt)
rec.ConfirmedAt = parseRFC3339Ptr(confirmedAt)
@ -1135,7 +1207,14 @@ func (s *store) putMemberChannelEvent(ctx context.Context, rec memberChannelEven
}
rec.EventID = "evt_" + id
}
_, err := s.db.ExecContext(ctx, `
dropped, err := s.applyMemberChannelEventThrottle(ctx, rec)
if err != nil {
return err
}
if dropped {
return nil
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO member_channel_events (
event_id, wallet, org_root_id, principal_id, class, created_at, title, body, dedupe_key, requires_ack, policy_hash, payload_json, visibility_scope
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@ -1144,6 +1223,205 @@ func (s *store) putMemberChannelEvent(ctx context.Context, rec memberChannelEven
return err
}
func (s *store) applyMemberChannelEventThrottle(ctx context.Context, rec memberChannelEventRecord) (bool, error) {
if s == nil {
return false, nil
}
limit := s.memberEventBurstLimit
window := s.memberEventBurstWindow
if limit <= 0 || window <= 0 {
return false, nil
}
if strings.EqualFold(strings.TrimSpace(rec.Class), s.memberEventDigestClass) {
return false, nil
}
wallet := strings.ToLower(strings.TrimSpace(rec.Wallet))
orgRootID := strings.TrimSpace(rec.OrgRootID)
if wallet == "" || orgRootID == "" {
return false, nil
}
createdAt := rec.CreatedAt.UTC()
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
windowStart := createdAt.Add(-window).UTC().Format(time.RFC3339Nano)
var recentCount int
if err := s.db.QueryRowContext(ctx, `
SELECT COUNT(1)
FROM member_channel_events
WHERE wallet = ? AND org_root_id = ? AND created_at >= ? AND class <> ?
`, wallet, orgRootID, windowStart, s.memberEventDigestClass).Scan(&recentCount); err != nil {
return false, err
}
if recentCount < limit {
return false, nil
}
if err := s.putMemberChannelDigestEvent(ctx, rec, createdAt, limit, window); err != nil {
return false, err
}
return true, nil
}
func (s *store) putMemberChannelDigestEvent(ctx context.Context, source memberChannelEventRecord, now time.Time, limit int, window time.Duration) error {
if s == nil {
return nil
}
if now.IsZero() {
now = time.Now().UTC()
}
if strings.TrimSpace(source.VisibilityScope) == "" {
source.VisibilityScope = s.memberEventDigestVisibility
}
wallet := strings.ToLower(strings.TrimSpace(source.Wallet))
orgRootID := strings.TrimSpace(source.OrgRootID)
if wallet == "" || orgRootID == "" {
return nil
}
windowStart := now.UTC().Truncate(window)
windowEnd := windowStart.Add(window)
dedupeKey := fmt.Sprintf("channel_digest:%d", windowStart.Unix())
existingPayload, existingPolicyHash, existingVisibilityScope, found, err := s.getMemberChannelDigestPayload(ctx, wallet, orgRootID, dedupeKey)
if err != nil {
return err
}
if found {
if existingPayload == nil {
existingPayload = map[string]any{}
}
suppressedCount := payloadCountValue(existingPayload["suppressed_count"])
existingPayload["suppressed_count"] = suppressedCount + 1
existingPayload["last_suppressed_class"] = strings.TrimSpace(source.Class)
existingPayload["last_suppressed_at"] = now.UTC().Format(time.RFC3339Nano)
existingPayload["window_ends_at"] = windowEnd.Format(time.RFC3339Nano)
if strings.TrimSpace(payloadStringValue(existingPayload["window_started_at"])) == "" {
existingPayload["window_started_at"] = windowStart.Format(time.RFC3339Nano)
}
rawPayload, _ := json.Marshal(existingPayload)
policyHash := strings.TrimSpace(source.PolicyHash)
if policyHash == "" {
policyHash = existingPolicyHash
}
visibilityScope := strings.TrimSpace(source.VisibilityScope)
if visibilityScope == "" {
visibilityScope = existingVisibilityScope
}
_, err = s.db.ExecContext(ctx, `
UPDATE member_channel_events
SET payload_json = ?, policy_hash = ?, visibility_scope = ?
WHERE wallet = ? AND org_root_id = ? AND dedupe_key = ?
`, string(rawPayload), policyHash, visibilityScope, wallet, orgRootID, dedupeKey)
return err
}
payload := map[string]any{
"digest_kind": "notification_throttle",
"dropped_class": strings.TrimSpace(source.Class),
"suppressed_count": 1,
"burst_limit": limit,
"window_seconds": int(window / time.Second),
"window_started_at": windowStart.Format(time.RFC3339Nano),
"window_ends_at": windowEnd.Format(time.RFC3339Nano),
"last_suppressed_at": now.UTC().Format(time.RFC3339Nano),
"last_suppressed_class": strings.TrimSpace(source.Class),
"visibility_scope": strings.TrimSpace(source.VisibilityScope),
"source_policy_hash": strings.TrimSpace(source.PolicyHash),
"source_principal_id": strings.TrimSpace(source.PrincipalID),
}
rawPayload, _ := json.Marshal(payload)
eventID, err := randomHex(8)
if err != nil {
return err
}
title := strings.TrimSpace(s.memberEventDigestTitle)
if title == "" {
title = "Update digest ready"
}
body := strings.TrimSpace(s.memberEventDigestBodyTemplate)
if body == "" {
body = "High activity detected. Open this digest for grouped updates."
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO member_channel_events (
event_id, wallet, org_root_id, principal_id, class, created_at, title, body, dedupe_key, requires_ack, policy_hash, payload_json, visibility_scope
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(wallet, org_root_id, dedupe_key) DO NOTHING
`, "evt_"+eventID,
wallet,
orgRootID,
strings.TrimSpace(source.PrincipalID),
s.memberEventDigestClass,
now.UTC().Format(time.RFC3339Nano),
title,
body,
dedupeKey,
1,
strings.TrimSpace(source.PolicyHash),
string(rawPayload),
strings.TrimSpace(source.VisibilityScope))
return err
}
func (s *store) getMemberChannelDigestPayload(ctx context.Context, wallet, orgRootID, dedupeKey string) (map[string]any, string, string, bool, error) {
row := s.db.QueryRowContext(ctx, `
SELECT payload_json, policy_hash, visibility_scope
FROM member_channel_events
WHERE wallet = ? AND org_root_id = ? AND dedupe_key = ? AND class = ?
LIMIT 1
`, wallet, orgRootID, dedupeKey, s.memberEventDigestClass)
var rawPayload sql.NullString
var policyHash sql.NullString
var visibilityScope sql.NullString
if err := row.Scan(&rawPayload, &policyHash, &visibilityScope); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, "", "", false, nil
}
return nil, "", "", false, err
}
payload := map[string]any{}
if rawPayload.Valid && strings.TrimSpace(rawPayload.String) != "" {
_ = json.Unmarshal([]byte(rawPayload.String), &payload)
}
return payload, strings.TrimSpace(policyHash.String), strings.TrimSpace(visibilityScope.String), true, nil
}
func payloadCountValue(v any) int {
switch n := v.(type) {
case int:
if n < 0 {
return 0
}
return n
case int64:
if n < 0 {
return 0
}
return int(n)
case float64:
if n < 0 {
return 0
}
return int(n)
case string:
trimmed := strings.TrimSpace(n)
if trimmed == "" {
return 0
}
var parsed int
if _, err := fmt.Sscanf(trimmed, "%d", &parsed); err != nil || parsed < 0 {
return 0
}
return parsed
default:
return 0
}
}
func payloadStringValue(v any) string {
if v == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(v))
}
func (s *store) getMemberChannelEventSeqByID(ctx context.Context, eventID string) (int64, error) {
row := s.db.QueryRowContext(ctx, `
SELECT seq

View File

@ -11,7 +11,7 @@ security:
paths:
/governance/install/token:
post:
summary: Authorize governance install for ownership wallet.
summary: Authorize governance install for ownership wallet (onramp_attested assurance required).
operationId: createGovernanceInstallToken
requestBody:
required: true
@ -27,7 +27,7 @@ paths:
schema:
$ref: '#/components/schemas/InstallTokenResponse'
'403':
description: Membership or governance entitlement inactive.
description: Membership inactive, identity assurance insufficient, or governance entitlement inactive.
content:
application/json:
schema:
@ -79,7 +79,7 @@ paths:
$ref: '#/components/schemas/InstallStatusResponse'
/governance/lease/heartbeat:
post:
summary: Refresh connected-class availability lease for org boundary.
summary: Refresh connected-class availability lease for org boundary (org_root_owner + onramp_attested required).
operationId: refreshGovernanceLeaseHeartbeat
requestBody:
required: true
@ -95,14 +95,14 @@ paths:
schema:
$ref: '#/components/schemas/LeaseHeartbeatResponse'
'403':
description: Boundary or entitlement invalid.
description: Membership/assurance/boundary/entitlement invalid.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/governance/lease/offline-renew:
post:
summary: Apply signed offline renewal package for sovereign class.
summary: Apply signed offline renewal package for sovereign class (org_root_owner + onramp_attested required).
operationId: applyOfflineRenewalPackage
requestBody:
required: true
@ -117,6 +117,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/OfflineRenewResponse'
'403':
description: Membership/assurance/boundary/entitlement invalid.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: Renewal package invalid or stale.
content:
@ -132,6 +138,7 @@ components:
description: |
Wallet session token issued by `POST /secret/wallet/verify`.
Send as `Authorization: Bearer <token>` (preferred) or `X-Edut-Session: <token>`.
Optional replay hardening: include `X-Edut-Device-Binding: <stable-device-secret>`.
schemas:
InstallTokenRequest:
type: object
@ -300,6 +307,7 @@ components:
type: string
reason:
type: string
description: Deterministic activation blocker reason (for example `membership_inactive`, `identity_assurance_insufficient`, `entitlement_inactive`, `availability_parked`).
LeaseHeartbeatRequest:
type: object
required:
@ -375,5 +383,8 @@ components:
type: string
code:
type: string
next_step:
type: string
description: Deterministic operator guidance for remediation/retry.
correlation_id:
type: string

View File

@ -127,6 +127,7 @@ components:
description: |
Wallet session token issued by `POST /secret/wallet/verify`.
Send as `Authorization: Bearer <token>` (preferred) or `X-Edut-Session: <token>`.
Optional replay hardening: include `X-Edut-Device-Binding: <stable-device-secret>`.
schemas:
Offer:
type: object
@ -196,6 +197,11 @@ components:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
description: Ownership wallet that will receive entitlement.
payment_path:
type: string
enum: [crypto_direct, fiat_onramp]
default: crypto_direct
description: Preferred checkout rail. `fiat_onramp` may be unavailable during dependency-edge degradation.
payer_wallet:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
@ -262,6 +268,12 @@ components:
$ref: '#/components/schemas/QuoteCostEnvelope'
membership_activation_included:
type: boolean
approval_required:
type: boolean
description: True when quote total exceeds configured financial threshold and confirm requires approval token + actor.
approval_reason:
type: string
description: Deterministic reason code when `approval_required=true`.
line_items:
type: array
items:
@ -271,6 +283,9 @@ components:
expires_at:
type: string
format: date-time
payment_path:
type: string
enum: [crypto_direct, fiat_onramp]
tx:
type: object
additionalProperties: true
@ -374,6 +389,12 @@ components:
enum: [workspace_member, org_root_owner]
workspace_id:
type: string
approval_token:
type: string
description: Required when quote response sets `approval_required=true`.
approval_actor:
type: string
description: Required when quote response sets `approval_required=true`; principal that supplied approval.
tx_hash:
type: string
pattern: '^0x[a-fA-F0-9]{64}$'
@ -406,6 +427,12 @@ components:
type: string
tx_hash:
type: string
approval_token_ref:
type: string
description: Hashed reference to approval token material retained for audit.
approval_actor:
type: string
description: Principal recorded as approval actor for threshold-gated confirmations.
policy_hash:
type: string
activated_at:

View File

@ -33,6 +33,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'503':
description: Cloud dependency edge unavailable.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/member/channel/device/unregister:
post:
summary: Remove a device channel binding.
@ -129,7 +135,7 @@ paths:
$ref: '#/components/schemas/ErrorResponse'
/member/channel/support/ticket:
post:
summary: Open support/admin ticket (org root owner only).
summary: Open support/admin ticket (org root owner + onramp_attested assurance).
operationId: createOwnerSupportTicket
requestBody:
required: true
@ -145,7 +151,13 @@ paths:
schema:
$ref: '#/components/schemas/SupportTicketResponse'
'403':
description: Principal is not org root owner.
description: Principal is not org root owner or identity assurance is insufficient.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'503':
description: Cloud dependency edge unavailable.
content:
application/json:
schema:
@ -159,6 +171,7 @@ components:
description: |
Wallet session token issued by `POST /secret/wallet/verify`.
Send as `Authorization: Bearer <token>` (preferred) or `X-Edut-Session: <token>`.
Optional replay hardening: include `X-Edut-Device-Binding: <stable-device-secret>`.
schemas:
DeviceRegisterRequest:
type: object
@ -254,6 +267,10 @@ components:
- principal_id
- membership_status
- identity_assurance_level
- digest_active
- digest_suppressed_count
- trusted_event_count
- review_event_count
- events
- next_cursor
- server_time
@ -272,6 +289,17 @@ components:
identity_assurance_level:
type: string
enum: [none, crypto_direct_unattested, sponsored_unattested, onramp_attested]
digest_active:
type: boolean
digest_suppressed_count:
type: integer
minimum: 0
trusted_event_count:
type: integer
minimum: 0
review_event_count:
type: integer
minimum: 0
events:
type: array
items:
@ -292,6 +320,8 @@ components:
- body
- dedupe_key
- policy_hash
- trust_posture
- review_level
properties:
event_id:
type: string
@ -306,6 +336,7 @@ components:
- admin_health
- admin_config
- admin_update
- channel_digest
created_at:
type: string
format: date-time
@ -320,6 +351,16 @@ components:
default: true
policy_hash:
type: string
trust_posture:
type: string
enum:
- policy_verified
- source_verified
- digest_aggregated
- unverified
review_level:
type: string
enum: [trusted, review]
payload:
type: object
additionalProperties: true
@ -366,6 +407,9 @@ components:
type: string
code:
type: string
next_step:
type: string
description: Deterministic operator guidance for remediation/retry.
correlation_id:
type: string
SupportTicketRequest:

View File

@ -212,6 +212,42 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MembershipStatusResponse'
/secret/setup/health:
get:
summary: Resolve deterministic setup readiness for wallet onboarding
description: Returns wallet/session/membership/assurance/principal readiness checks for operator troubleshooting.
parameters:
- $ref: '#/components/parameters/WalletSessionHeader'
- in: query
name: wallet
required: true
schema:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
responses:
'200':
description: Setup health resolved
content:
application/json:
schema:
$ref: '#/components/schemas/SetupHealthResponse'
/secret/id/mint-payload:
post:
summary: Build direct EDUT ID mint transaction payload
description: Returns deterministic transaction fields for direct wallet mint (gas-only, no checkout quote).
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/IDMintPayloadRequest'
responses:
'200':
description: Mint payload ready
content:
application/json:
schema:
$ref: '#/components/schemas/IDMintPayloadResponse'
components:
parameters:
WalletSessionHeader:
@ -220,7 +256,7 @@ components:
required: false
schema:
type: string
description: Wallet session token. `Authorization: Bearer <token>` is also accepted.
description: Wallet session token. `Authorization: Bearer <token>` is also accepted. Optional replay hardening uses `X-Edut-Device-Binding`.
schemas:
WalletIntentRequest:
type: object
@ -345,6 +381,11 @@ components:
pattern: '^0x[a-fA-F0-9]{40}$'
chain_id:
type: integer
payment_path:
type: string
enum: [crypto_direct, fiat_onramp]
default: crypto_direct
description: Preferred checkout rail. `fiat_onramp` may be unavailable during dependency-edge degradation.
payer_wallet:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
@ -366,7 +407,7 @@ components:
enum: [us_general_2026, eu_ai_act_2026_baseline]
currency:
type: string
enum: [USDC]
enum: [USDC, ETH]
amount_atomic:
type: string
decimals:
@ -388,6 +429,9 @@ components:
type: string
payer_wallet:
type: string
payment_path:
type: string
enum: [crypto_direct, fiat_onramp]
sponsorship_mode:
type: string
enum: [self, sponsored, sponsored_company]
@ -517,3 +561,93 @@ components:
type: string
identity_attestation_id:
type: string
SetupHealthCheck:
type: object
required: [name, status]
properties:
name:
type: string
status:
type: string
enum: [pass, fail]
reason:
type: string
next_step:
type: string
SetupHealthResponse:
type: object
required:
- wallet
- chain_id
- membership_status
- identity_assurance_level
- principal_present
- ready_for_checkout
- ready_for_admin
- checks
- server_time
properties:
wallet:
type: string
chain_id:
type: integer
membership_status:
type: string
identity_assurance_level:
type: string
principal_present:
type: boolean
principal_role:
type: string
entitlement_status:
type: string
availability_state:
type: string
ready_for_checkout:
type: boolean
ready_for_admin:
type: boolean
checks:
type: array
items:
$ref: '#/components/schemas/SetupHealthCheck'
next_steps:
type: array
items:
type: string
server_time:
type: string
format: date-time
IDMintPayloadRequest:
type: object
required: [address, chain_id]
properties:
address:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
chain_id:
type: integer
IDMintPayloadResponse:
type: object
required: [status, chain_id, regulatory_profile_id, contract_address, method, calldata, value, tx]
properties:
status:
type: string
enum: [id_mint_payload_ready]
chain_id:
type: integer
regulatory_profile_id:
type: string
enum: [us_general_2026, eu_ai_act_2026_baseline]
contract_address:
type: string
method:
type: string
enum: [mintMembership(address)]
calldata:
type: string
value:
type: string
tx:
type: object
additionalProperties: true

View File

@ -94,6 +94,71 @@ This document defines deterministic vectors for org-boundary enforcement and ava
- When attempting org-level config mutation
- Then request is denied
## Vector Group AB6: Dependency-Edge Degraded Modes
1. `AB6-001` chain_edge_degraded_blocks_new_settlement
- Given chain dependency state is `DEGRADED` (read or write unavailable)
- And caller attempts an action requiring fresh on-chain settlement or entitlement mutation
- Then action is blocked with `dependency.chain_unavailable`
- And UI surfaces: "Chain verification unavailable. Retry when network health is restored."
2. `AB6-002` chain_edge_degraded_preserves_local_read_paths
- Given chain dependency state is `DEGRADED`
- When user opens existing workspace data, local cards, exports, and prior verified entitlements
- Then read/search/export paths remain available
- And no silent entitlement promotion occurs while chain is degraded
3. `AB6-003` onramp_edge_degraded_keeps_crypto_direct_path_active
- Given on-ramp dependency state is `DEGRADED`
- When user initiates checkout
- Then `crypto_direct` path remains available
- And `fiat_onramp` path is disabled with `dependency.onramp_unavailable`
- And user guidance indicates card/Apple Pay path is temporarily unavailable
4. `AB6-004` cloud_edge_degraded_preserves_local_runtime
- Given Edut cloud/API edge is unavailable
- When owner runs previously installed governed modules locally
- Then governed local execution remains available
- And remote-dependent operations (catalog sync, push delivery, cloud webhooks) are paused with deterministic reason codes
5. `AB6-005` model_edge_degraded_fails_closed_for_unsafe_ai_actions
- Given model-provider edge is degraded or unavailable
- When lane step requires model inference for non-deterministic generation
- Then step transitions to blocked/deferred with `dependency.model_unavailable`
- And deterministic math-only guards continue for validation and policy checks
6. `AB6-006` degraded_mode_exit_requires_explicit_health_recovery
- Given any dependency edge is in degraded state
- When health probes return normal
- Then system remains in degraded mode until recovery criteria are met for configured stability window
- And recovery transition emits auditable state event with edge id and timestamp
7. `AB6-007` tls_edge_degraded_blocks_chain_settlement_confirm
- Given TLS dependency state is `DEGRADED`
- And caller attempts membership or marketplace settlement confirmation
- Then action is blocked with `dependency.tls_unavailable`
- And deterministic guidance indicates TLS/certificate health recovery is required
8. `AB6-008` dns_edge_degraded_blocks_chain_settlement_confirm
- Given DNS dependency state is `DEGRADED`
- And caller attempts membership or marketplace settlement confirmation
- Then action is blocked with `dependency.dns_unavailable`
- And deterministic guidance indicates DNS resolution health recovery is required
9. `AB6-009` tls_edge_recovery_honors_stability_window
- Given TLS dependency state transitions from `DEGRADED` to `HEALTHY`
- And recovery stability window is configured
- When a settlement confirmation is retried before stability window completion
- Then action remains blocked with `dependency.tls_unavailable`
- And once stability window elapses, confirmation succeeds without manual override
10. `AB6-010` dns_edge_recovery_honors_stability_window
- Given DNS dependency state transitions from `DEGRADED` to `HEALTHY`
- And recovery stability window is configured
- When a settlement confirmation is retried before stability window completion
- Then action remains blocked with `dependency.dns_unavailable`
- And once stability window elapses, confirmation succeeds without manual override
## Pass Criteria
Build is conformant only when all vectors pass.