api: add dependency-edge degraded modes and conformance tests
Some checks are pending
check / secretapi (push) Waiting to run
Some checks are pending
check / secretapi (push) Waiting to run
This commit is contained in:
parent
cbcf027d97
commit
496d8cf97a
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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")
|
||||
|
||||
114
backend/secretapi/config_docs_parity_test.go
Normal file
114
backend/secretapi/config_docs_parity_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
277
backend/secretapi/dependency_edges.go
Normal file
277
backend/secretapi/dependency_edges.go
Normal 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
|
||||
}
|
||||
114
backend/secretapi/dependency_edges_test.go
Normal file
114
backend/secretapi/dependency_edges_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
119
backend/secretapi/openapi_parity_test.go
Normal file
119
backend/secretapi/openapi_parity_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user