From 27b5900205896708de2d8eb63b413ddef4af5d53 Mon Sep 17 00:00:00 2001 From: Joshua Date: Tue, 17 Feb 2026 20:48:19 -0800 Subject: [PATCH] Add secretapi member channel endpoints and deployment hardening --- backend/secretapi/.env.example | 28 + backend/secretapi/Dockerfile | 16 + backend/secretapi/README.md | 98 ++ backend/secretapi/app.go | 1359 +++++++++++++++++ backend/secretapi/app_test.go | 564 +++++++ backend/secretapi/chain.go | 75 + backend/secretapi/config.go | 83 + backend/secretapi/crypto.go | 132 ++ backend/secretapi/deploy/secretapi.service | 23 + backend/secretapi/go.mod | 39 + backend/secretapi/go.sum | 67 + backend/secretapi/main.go | 32 + backend/secretapi/models.go | 383 +++++ backend/secretapi/store.go | 788 ++++++++++ docs/api/examples/secret-system.examples.md | 44 +- docs/api/secret-system.openapi.yaml | 17 + docs/deployment/README.md | 3 + docs/deployment/secretapi-deploy.md | 77 + docs/governance-install-spec.md | 21 + docs/handoff/governance-backend-checklist.md | 2 + .../member-channel-backend-checklist.md | 5 + docs/handoff/membership-backend-checklist.md | 16 + docs/roadmap-status.md | 14 +- 23 files changed, 3869 insertions(+), 17 deletions(-) create mode 100644 backend/secretapi/.env.example create mode 100644 backend/secretapi/Dockerfile create mode 100644 backend/secretapi/README.md create mode 100644 backend/secretapi/app.go create mode 100644 backend/secretapi/app_test.go create mode 100644 backend/secretapi/chain.go create mode 100644 backend/secretapi/config.go create mode 100644 backend/secretapi/crypto.go create mode 100644 backend/secretapi/deploy/secretapi.service create mode 100644 backend/secretapi/go.mod create mode 100644 backend/secretapi/go.sum create mode 100644 backend/secretapi/main.go create mode 100644 backend/secretapi/models.go create mode 100644 backend/secretapi/store.go create mode 100644 docs/deployment/secretapi-deploy.md diff --git a/backend/secretapi/.env.example b/backend/secretapi/.env.example new file mode 100644 index 0000000..4f3c080 --- /dev/null +++ b/backend/secretapi/.env.example @@ -0,0 +1,28 @@ +SECRET_API_LISTEN_ADDR=:8080 +SECRET_API_DB_PATH=./secret.db +SECRET_API_ALLOWED_ORIGIN=https://edut.ai +SECRET_API_MEMBER_POLL_INTERVAL_SECONDS=30 + +SECRET_API_CHAIN_ID=84532 +SECRET_API_CHAIN_RPC_URL= + +SECRET_API_INTENT_TTL_SECONDS=900 +SECRET_API_QUOTE_TTL_SECONDS=900 +SECRET_API_INSTALL_TOKEN_TTL_SECONDS=900 +SECRET_API_LEASE_TTL_SECONDS=3600 +SECRET_API_OFFLINE_RENEW_TTL_SECONDS=2592000 + +SECRET_API_DOMAIN_NAME=EDUT Designation +SECRET_API_VERIFYING_CONTRACT=0x0000000000000000000000000000000000000000 +SECRET_API_MEMBERSHIP_CONTRACT=0x0000000000000000000000000000000000000000 +SECRET_API_MINT_CURRENCY=ETH +SECRET_API_MINT_AMOUNT_ATOMIC=5000000000000000 +SECRET_API_MINT_DECIMALS=18 + +SECRET_API_GOV_RUNTIME_VERSION=0.1.0 +SECRET_API_GOV_PACKAGE_URL=https://cdn.edut.ai/governance/edutd-0.1.0.tar.gz +SECRET_API_GOV_PACKAGE_HASH=sha256:pending +SECRET_API_GOV_PACKAGE_SIGNATURE=pending +SECRET_API_GOV_SIGNER_KEY_ID=edut-signer-1 +SECRET_API_GOV_POLICY_HASH=sha256:pending +SECRET_API_GOV_ROLLOUT_CHANNEL=stable diff --git a/backend/secretapi/Dockerfile b/backend/secretapi/Dockerfile new file mode 100644 index 0000000..d15974c --- /dev/null +++ b/backend/secretapi/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.24-alpine AS build + +WORKDIR /src +COPY . . +RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o /out/secretapi . + +FROM alpine:3.20 + +RUN addgroup -S edut && adduser -S -G edut edut +WORKDIR /app +COPY --from=build /out/secretapi /usr/local/bin/secretapi + +USER edut +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/secretapi"] + diff --git a/backend/secretapi/README.md b/backend/secretapi/README.md new file mode 100644 index 0000000..18158a4 --- /dev/null +++ b/backend/secretapi/README.md @@ -0,0 +1,98 @@ +# Secret API Backend (`secretapi`) + +Deterministic backend for wallet-first designation, membership activation, and governance install authorization. + +## Run + +```bash +cd /Users/vsg/Documents/VSG\ Codex/web/backend/secretapi +go run . +``` + +Default listen address is `:8080`. + +## Test + +```bash +cd /Users/vsg/Documents/VSG\ Codex/web/backend/secretapi +go test ./... +``` + +## Endpoint Surface + +### Membership + +- `POST /secret/wallet/intent` +- `POST /secret/wallet/verify` +- `POST /secret/membership/quote` +- `POST /secret/membership/confirm` +- `GET /secret/membership/status` + +### Governance install + availability + +- `POST /governance/install/token` +- `POST /governance/install/confirm` +- `GET /governance/install/status` +- `POST /governance/lease/heartbeat` +- `POST /governance/lease/offline-renew` + +### Member app channel + +- `POST /member/channel/device/register` +- `POST /member/channel/device/unregister` +- `GET /member/channel/events` +- `POST /member/channel/events/{event_id}/ack` +- `POST /member/channel/support/ticket` + +## Sponsorship Behavior + +Membership quote supports ownership wallet and distinct payer wallet: + +- `address`: ownership wallet (required) +- `payer_wallet`: optional payer wallet +- `payer_proof`: required when payer differs from owner + +Distinct payer proof uses owner-signed personal message: + +`EDUT-PAYER-AUTH:{designation_code}:{owner_wallet}:{payer_wallet}:{chain_id}` + +This enables company-sponsored mint flows while preserving deterministic owner authorization. + +Company-first sponsor path is also supported: + +- If `sponsor_org_root_id` is provided and the `payer_wallet` is a stored `org_root_owner` principal for that org root with active entitlement status, quote issuance is allowed without `payer_proof`. + +## Key Environment Variables + +### Core + +- `SECRET_API_LISTEN_ADDR` (default `:8080`) +- `SECRET_API_DB_PATH` (default `./secret.db`) +- `SECRET_API_ALLOWED_ORIGIN` (default `https://edut.ai`) +- `SECRET_API_MEMBER_POLL_INTERVAL_SECONDS` (default `30`) +- `SECRET_API_CHAIN_ID` (default `84532`) +- `SECRET_API_CHAIN_RPC_URL` (optional, enables on-chain tx receipt verification) + +### Membership + +- `SECRET_API_INTENT_TTL_SECONDS` (default `900`) +- `SECRET_API_QUOTE_TTL_SECONDS` (default `900`) +- `SECRET_API_DOMAIN_NAME` +- `SECRET_API_VERIFYING_CONTRACT` +- `SECRET_API_MEMBERSHIP_CONTRACT` +- `SECRET_API_MINT_CURRENCY` (default `ETH`) +- `SECRET_API_MINT_AMOUNT_ATOMIC` (default `5000000000000000`) +- `SECRET_API_MINT_DECIMALS` (default `18`) + +### Governance install + +- `SECRET_API_INSTALL_TOKEN_TTL_SECONDS` (default `900`) +- `SECRET_API_LEASE_TTL_SECONDS` (default `3600`) +- `SECRET_API_OFFLINE_RENEW_TTL_SECONDS` (default `2592000`) +- `SECRET_API_GOV_RUNTIME_VERSION` +- `SECRET_API_GOV_PACKAGE_URL` +- `SECRET_API_GOV_PACKAGE_HASH` +- `SECRET_API_GOV_PACKAGE_SIGNATURE` +- `SECRET_API_GOV_SIGNER_KEY_ID` +- `SECRET_API_GOV_POLICY_HASH` +- `SECRET_API_GOV_ROLLOUT_CHANNEL` (default `stable`) diff --git a/backend/secretapi/app.go b/backend/secretapi/app.go new file mode 100644 index 0000000..bacab20 --- /dev/null +++ b/backend/secretapi/app.go @@ -0,0 +1,1359 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math/big" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +var ( + reDigits = regexp.MustCompile(`^[0-9]+$`) +) + +type app struct { + cfg Config + store *store +} + +func newApp(cfg Config, st *store) *app { + return &app{cfg: cfg, store: st} +} + +func (a *app) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", a.withCORS(a.handleHealth)) + mux.HandleFunc("/secret/wallet/intent", a.withCORS(a.handleWalletIntent)) + mux.HandleFunc("/secret/wallet/verify", a.withCORS(a.handleWalletVerify)) + mux.HandleFunc("/secret/membership/quote", a.withCORS(a.handleMembershipQuote)) + mux.HandleFunc("/secret/membership/confirm", a.withCORS(a.handleMembershipConfirm)) + mux.HandleFunc("/secret/membership/status", a.withCORS(a.handleMembershipStatus)) + mux.HandleFunc("/governance/install/token", a.withCORS(a.handleGovernanceInstallToken)) + mux.HandleFunc("/governance/install/confirm", a.withCORS(a.handleGovernanceInstallConfirm)) + mux.HandleFunc("/governance/install/status", a.withCORS(a.handleGovernanceInstallStatus)) + mux.HandleFunc("/governance/lease/heartbeat", a.withCORS(a.handleGovernanceLeaseHeartbeat)) + mux.HandleFunc("/governance/lease/offline-renew", a.withCORS(a.handleGovernanceOfflineRenew)) + mux.HandleFunc("/member/channel/device/register", a.withCORS(a.handleMemberChannelDeviceRegister)) + mux.HandleFunc("/member/channel/device/unregister", a.withCORS(a.handleMemberChannelDeviceUnregister)) + mux.HandleFunc("/member/channel/events", a.withCORS(a.handleMemberChannelEvents)) + mux.HandleFunc("/member/channel/events/", a.withCORS(a.handleMemberChannelEventAck)) + mux.HandleFunc("/member/channel/support/ticket", a.withCORS(a.handleMemberChannelSupportTicket)) + return mux +} + +func (a *app) withCORS(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + origin = a.cfg.AllowedOrigin + } + if a.cfg.AllowedOrigin == "*" || strings.EqualFold(origin, a.cfg.AllowedOrigin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + } + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next(w, r) + } +} + +func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (a *app) handleWalletIntent(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req walletIntentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + address, err := normalizeAddress(req.Address) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if req.ChainID != a.cfg.ChainID { + writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported chain_id: %d", req.ChainID)) + return + } + intentID, err := randomHex(16) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate intent id") + return + } + nonce, err := randomHex(16) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate nonce") + return + } + code, displayToken, err := newDesignationCode() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate designation code") + return + } + issuedAt := time.Now().UTC() + expiresAt := issuedAt.Add(a.cfg.IntentTTL) + + record := designationRecord{ + Code: code, + DisplayToken: displayToken, + IntentID: intentID, + Nonce: nonce, + Origin: strings.TrimSpace(req.Origin), + Locale: strings.TrimSpace(req.Locale), + Address: address, + ChainID: req.ChainID, + IssuedAt: issuedAt, + ExpiresAt: expiresAt, + MembershipStatus: "none", + } + if record.Origin == "" { + record.Origin = "https://edut.ai" + } + if record.Locale == "" { + record.Locale = "en" + } + if err := a.store.putDesignation(r.Context(), record); err != nil { + writeError(w, http.StatusInternalServerError, "failed to persist designation intent") + return + } + + writeJSON(w, http.StatusOK, walletIntentResponse{ + IntentID: intentID, + DesignationCode: code, + DisplayToken: displayToken, + Nonce: nonce, + IssuedAt: issuedAt.Format(time.RFC3339Nano), + ExpiresAt: expiresAt.Format(time.RFC3339Nano), + DomainName: a.cfg.DomainName, + ChainID: a.cfg.ChainID, + VerifyingContract: a.cfg.VerifyingContract, + }) +} + +func (a *app) handleWalletVerify(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req walletVerifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + address, err := normalizeAddress(req.Address) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if req.ChainID != a.cfg.ChainID { + writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported chain_id: %d", req.ChainID)) + return + } + + rec, err := a.store.getDesignationByIntent(r.Context(), strings.TrimSpace(req.IntentID)) + if err != nil { + if errors.Is(err, errNotFound) { + writeError(w, http.StatusNotFound, "intent not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load intent") + return + } + if time.Now().UTC().After(rec.ExpiresAt) { + writeError(w, http.StatusBadRequest, "intent expired") + return + } + if strings.ToLower(rec.Address) != address { + writeError(w, http.StatusBadRequest, "address does not match intent") + return + } + typedData := buildTypedData(a.cfg, rec) + recovered, err := recoverSignerAddress(typedData, req.Signature) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("signature verification failed: %v", err)) + return + } + if recovered != address { + writeError(w, http.StatusBadRequest, "signature signer does not match address") + return + } + + now := time.Now().UTC() + rec.VerifiedAt = &now + if err := a.store.putDesignation(r.Context(), rec); err != nil { + writeError(w, http.StatusInternalServerError, "failed to store verification status") + return + } + + writeJSON(w, http.StatusOK, walletVerifyResponse{ + Status: "signature_verified", + DesignationCode: rec.Code, + DisplayToken: rec.DisplayToken, + VerifiedAt: now.Format(time.RFC3339Nano), + }) +} + +func (a *app) handleMembershipQuote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req membershipQuoteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + address, err := normalizeAddress(req.Address) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if req.ChainID != a.cfg.ChainID { + writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported chain_id: %d", req.ChainID)) + return + } + payerAddress := address + if strings.TrimSpace(req.PayerWallet) != "" { + payerAddress, err = normalizeAddress(req.PayerWallet) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + } + rec, err := a.store.getDesignationByCode(r.Context(), strings.TrimSpace(req.DesignationCode)) + if err != nil { + if errors.Is(err, errNotFound) { + writeError(w, http.StatusNotFound, "designation not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load designation") + return + } + if rec.VerifiedAt == nil { + writeError(w, http.StatusConflict, "designation not verified") + return + } + if strings.ToLower(rec.Address) != address { + writeError(w, http.StatusBadRequest, "address does not match designation") + return + } + sponsorshipMode := "self" + if payerAddress != address { + sponsorOrgRoot := strings.TrimSpace(req.SponsorOrgRoot) + if strings.TrimSpace(req.PayerProof) != "" { + if err := verifyDistinctPayerProof(rec.Code, address, payerAddress, req.ChainID, req.PayerProof); err != nil { + writeError(w, http.StatusForbidden, fmt.Sprintf("invalid ownership proof: %v", err)) + return + } + sponsorshipMode = "sponsored" + } else if sponsorOrgRoot != "" { + payerPrincipal, principalErr := a.store.getGovernancePrincipal(r.Context(), payerAddress) + if principalErr != nil { + writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor wallet is not authorized for org root") + return + } + if !strings.EqualFold(strings.TrimSpace(payerPrincipal.OrgRootID), sponsorOrgRoot) { + writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor org root mismatch") + return + } + if !strings.EqualFold(strings.TrimSpace(payerPrincipal.PrincipalRole), "org_root_owner") { + writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor wallet requires org_root_owner role") + return + } + if !strings.EqualFold(strings.TrimSpace(payerPrincipal.EntitlementStatus), "active") { + writeErrorCode(w, http.StatusForbidden, "sponsor_not_authorized", "sponsor entitlement is not active") + return + } + sponsorshipMode = "sponsored_company" + } else { + writeError(w, http.StatusForbidden, "distinct payer requires ownership proof") + return + } + } + + quoteID, err := randomHex(16) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate quote id") + return + } + calldata, err := encodeMintMembershipCalldata(address) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to encode mint calldata") + return + } + now := time.Now().UTC() + expiresAt := now.Add(a.cfg.QuoteTTL) + valueHex := "0x0" + if strings.EqualFold(a.cfg.MintCurrency, "ETH") { + amount, ok := new(big.Int).SetString(a.cfg.MintAmountAtomic, 10) + if !ok { + writeError(w, http.StatusInternalServerError, "invalid mint amount configuration") + return + } + valueHex = "0x" + amount.Text(16) + } + + quote := quoteRecord{ + QuoteID: quoteID, + DesignationCode: rec.Code, + Address: address, + PayerAddress: payerAddress, + ChainID: a.cfg.ChainID, + Currency: a.cfg.MintCurrency, + AmountAtomic: a.cfg.MintAmountAtomic, + Decimals: a.cfg.MintDecimals, + ContractAddress: strings.ToLower(a.cfg.MembershipContract), + Method: "mintMembership(address)", + Calldata: calldata, + ValueHex: valueHex, + SponsorshipMode: sponsorshipMode, + SponsorOrgRootID: strings.TrimSpace(req.SponsorOrgRoot), + CreatedAt: now, + ExpiresAt: expiresAt, + } + if err := a.store.putQuote(r.Context(), quote); err != nil { + writeError(w, http.StatusInternalServerError, "failed to persist quote") + return + } + + tx := map[string]any{ + "from": payerAddress, + "to": quote.ContractAddress, + "data": quote.Calldata, + "value": quote.ValueHex, + } + writeJSON(w, http.StatusOK, membershipQuoteResponse{ + QuoteID: quote.QuoteID, + ChainID: quote.ChainID, + Currency: quote.Currency, + AmountAtomic: quote.AmountAtomic, + Decimals: quote.Decimals, + Deadline: quote.ExpiresAt.Format(time.RFC3339Nano), + ContractAddress: quote.ContractAddress, + Method: quote.Method, + Calldata: quote.Calldata, + Value: quote.ValueHex, + OwnerWallet: address, + PayerWallet: payerAddress, + SponsorshipMode: sponsorshipMode, + SponsorOrgRoot: strings.TrimSpace(req.SponsorOrgRoot), + Tx: tx, + }) +} + +func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req membershipConfirmRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + address, err := normalizeAddress(req.Address) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if req.ChainID != a.cfg.ChainID { + writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported chain_id: %d", req.ChainID)) + return + } + if !isTxHash(req.TxHash) { + writeError(w, http.StatusBadRequest, "invalid tx_hash") + return + } + + quote, err := a.store.getQuote(r.Context(), strings.TrimSpace(req.QuoteID)) + if err != nil { + if errors.Is(err, errNotFound) { + writeError(w, http.StatusNotFound, "quote not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load quote") + return + } + if time.Now().UTC().After(quote.ExpiresAt) { + writeError(w, http.StatusConflict, "quote expired") + return + } + if !strings.EqualFold(quote.Address, address) || !strings.EqualFold(quote.DesignationCode, req.DesignationCode) || quote.ChainID != req.ChainID { + writeError(w, http.StatusBadRequest, "quote context mismatch") + return + } + + if err := verifyMintedOnChain(context.Background(), a.cfg, req.TxHash, address); err != nil { + writeError(w, http.StatusConflict, fmt.Sprintf("tx verification pending/failed: %v", err)) + return + } + + rec, err := a.store.getDesignationByCode(r.Context(), quote.DesignationCode) + if err != nil { + if errors.Is(err, errNotFound) { + writeError(w, http.StatusNotFound, "designation not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load designation") + return + } + now := time.Now().UTC() + rec.MembershipStatus = "active" + rec.MembershipTxHash = strings.ToLower(req.TxHash) + rec.ActivatedAt = &now + if err := a.store.putDesignation(r.Context(), rec); err != nil { + writeError(w, http.StatusInternalServerError, "failed to persist membership activation") + return + } + + quote.ConfirmedAt = &now + quote.ConfirmedTxHash = strings.ToLower(req.TxHash) + if err := a.store.putQuote(r.Context(), quote); err != nil { + writeError(w, http.StatusInternalServerError, "failed to persist quote confirmation") + return + } + + writeJSON(w, http.StatusOK, membershipConfirmResponse{ + Status: "membership_active", + DesignationCode: rec.Code, + DisplayToken: rec.DisplayToken, + TxHash: strings.ToLower(req.TxHash), + ActivatedAt: now.Format(time.RFC3339Nano), + }) +} + +func (a *app) handleMembershipStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + wallet := strings.TrimSpace(r.URL.Query().Get("wallet")) + code := strings.TrimSpace(r.URL.Query().Get("designation_code")) + if wallet == "" && code == "" { + writeError(w, http.StatusBadRequest, "wallet or designation_code required") + return + } + + var ( + rec designationRecord + err error + ) + if code != "" { + rec, err = a.store.getDesignationByCode(r.Context(), code) + } else { + normalized, normalizeErr := normalizeAddress(wallet) + if normalizeErr != nil { + writeError(w, http.StatusBadRequest, normalizeErr.Error()) + return + } + rec, err = a.store.getDesignationByAddress(r.Context(), normalized) + } + + if err != nil { + if errors.Is(err, errNotFound) { + writeJSON(w, http.StatusOK, membershipStatusResponse{Status: "none"}) + return + } + writeError(w, http.StatusInternalServerError, "failed to resolve membership status") + return + } + + status := strings.ToLower(strings.TrimSpace(rec.MembershipStatus)) + if status == "" { + status = "none" + } + writeJSON(w, http.StatusOK, membershipStatusResponse{ + Status: status, + Wallet: rec.Address, + DesignationCode: rec.Code, + }) +} + +func (a *app) handleGovernanceInstallToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req governanceInstallTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body") + return + } + wallet, err := normalizeAddress(req.Wallet) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) + return + } + if strings.TrimSpace(req.DeviceID) == "" || strings.TrimSpace(req.Platform) == "" || strings.TrimSpace(req.LauncherVersion) == "" { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id, platform, launcher_version required") + return + } + + rec, err := a.store.getDesignationByAddress(r.Context(), wallet) + if err != nil { + if errors.Is(err, errNotFound) { + writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership not active") + return + } + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve membership") + return + } + if strings.ToLower(strings.TrimSpace(rec.MembershipStatus)) != "active" { + writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership not active") + return + } + + principal, err := a.resolveOrCreatePrincipal(r.Context(), wallet, req.OrgRootID, req.PrincipalID, req.PrincipalRole) + if err != nil { + writeErrorCode(w, http.StatusInternalServerError, "principal_resolve_failed", err.Error()) + return + } + if !strings.EqualFold(strings.TrimSpace(principal.PrincipalRole), "org_root_owner") { + writeErrorCode(w, http.StatusForbidden, "role_insufficient", "install/update controls require org_root_owner") + return + } + if strings.ToLower(strings.TrimSpace(principal.EntitlementStatus)) != "active" { + writeErrorCode(w, http.StatusForbidden, "entitlement_inactive", "governance entitlement inactive") + return + } + if strings.ToLower(strings.TrimSpace(principal.AvailabilityState)) == "parked" { + writeErrorCode(w, http.StatusForbidden, "availability_parked", "availability state parked blocks activation") + return + } + + installToken, err := randomHex(24) + if err != nil { + writeErrorCode(w, http.StatusInternalServerError, "token_generation_failed", "failed to issue install token") + return + } + now := time.Now().UTC() + expiresAt := now.Add(a.cfg.InstallTokenTTL) + tokenRec := governanceInstallTokenRecord{ + InstallToken: installToken, + Wallet: wallet, + OrgRootID: principal.OrgRootID, + PrincipalID: principal.PrincipalID, + PrincipalRole: principal.PrincipalRole, + DeviceID: strings.TrimSpace(req.DeviceID), + EntitlementID: principal.EntitlementID, + PackageHash: a.cfg.GovernancePackageHash, + RuntimeVersion: a.cfg.GovernanceRuntimeVersion, + PolicyHash: a.cfg.GovernancePolicyHash, + IssuedAt: now, + ExpiresAt: expiresAt, + } + if err := a.store.putGovernanceInstallToken(r.Context(), tokenRec); err != nil { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist install token") + return + } + + writeJSON(w, http.StatusOK, governanceInstallTokenResponse{ + InstallToken: installToken, + InstallTokenExpiresAt: expiresAt.Format(time.RFC3339Nano), + Wallet: wallet, + EntitlementID: principal.EntitlementID, + Package: governancePackage{ + RuntimeVersion: a.cfg.GovernanceRuntimeVersion, + PackageURL: a.cfg.GovernancePackageURL, + PackageHash: a.cfg.GovernancePackageHash, + Signature: a.cfg.GovernancePackageSig, + SignerKeyID: a.cfg.GovernanceSignerKeyID, + PolicyHash: a.cfg.GovernancePolicyHash, + RolloutChannel: a.cfg.GovernanceRolloutChannel, + }, + }) +} + +func (a *app) handleGovernanceInstallConfirm(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req governanceInstallConfirmRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body") + return + } + wallet, err := normalizeAddress(req.Wallet) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) + return + } + if strings.TrimSpace(req.InstallToken) == "" || strings.TrimSpace(req.DeviceID) == "" { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "install_token and device_id required") + return + } + + tokenRec, err := a.store.getGovernanceInstallToken(r.Context(), strings.TrimSpace(req.InstallToken)) + if err != nil { + if errors.Is(err, errNotFound) { + writeErrorCode(w, http.StatusNotFound, "install_token_not_found", "install token not found") + return + } + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to load install token") + return + } + if time.Now().UTC().After(tokenRec.ExpiresAt) { + writeErrorCode(w, http.StatusConflict, "install_token_expired", "install token expired") + return + } + if tokenRec.ConsumedAt != nil { + if existing, existingErr := a.store.getGovernanceInstallByToken(r.Context(), tokenRec.InstallToken); existingErr == nil { + writeJSON(w, http.StatusOK, governanceInstallConfirmResponse{ + Status: "governance_active", + Wallet: existing.Wallet, + DeviceID: existing.DeviceID, + EntitlementID: existing.EntitlementID, + RuntimeVersion: existing.RuntimeVersion, + ActivatedAt: existing.ActivatedAt.Format(time.RFC3339Nano), + }) + return + } + writeErrorCode(w, http.StatusConflict, "install_token_consumed", "install token already consumed") + return + } + if tokenRec.Wallet != wallet || tokenRec.DeviceID != strings.TrimSpace(req.DeviceID) { + writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "install token context mismatch") + return + } + if !strings.EqualFold(strings.TrimSpace(req.EntitlementID), tokenRec.EntitlementID) { + writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "entitlement mismatch") + return + } + if !strings.EqualFold(strings.TrimSpace(req.PackageHash), tokenRec.PackageHash) { + writeErrorCode(w, http.StatusConflict, "package_hash_mismatch", "package hash mismatch") + return + } + if !strings.EqualFold(strings.TrimSpace(req.RuntimeVersion), tokenRec.RuntimeVersion) { + writeErrorCode(w, http.StatusConflict, "runtime_version_mismatch", "runtime version mismatch") + return + } + + principal, err := a.store.getGovernancePrincipal(r.Context(), wallet) + if err != nil { + writeErrorCode(w, http.StatusInternalServerError, "principal_lookup_failed", "failed to resolve principal") + return + } + if strings.ToLower(strings.TrimSpace(principal.EntitlementStatus)) != "active" { + writeErrorCode(w, http.StatusConflict, "entitlement_inactive", "entitlement inactive") + return + } + if strings.ToLower(strings.TrimSpace(principal.AvailabilityState)) == "parked" { + writeErrorCode(w, http.StatusConflict, "availability_parked", "availability parked") + return + } + + installedAt := time.Now().UTC() + if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.InstalledAt)); err == nil { + installedAt = parsed.UTC() + } + activatedAt := time.Now().UTC() + installRec := governanceInstallRecord{ + InstallToken: tokenRec.InstallToken, + Wallet: wallet, + DeviceID: strings.TrimSpace(req.DeviceID), + EntitlementID: tokenRec.EntitlementID, + RuntimeVersion: tokenRec.RuntimeVersion, + PackageHash: tokenRec.PackageHash, + PolicyHash: tokenRec.PolicyHash, + LauncherReceiptRef: strings.TrimSpace(req.LauncherReceiptRef), + InstalledAt: installedAt, + ActivatedAt: activatedAt, + } + if err := a.store.putGovernanceInstall(r.Context(), installRec); err != nil { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist install confirmation") + return + } + tokenRec.ConsumedAt = &activatedAt + if err := a.store.putGovernanceInstallToken(r.Context(), tokenRec); err != nil { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to mark install token consumed") + return + } + + writeJSON(w, http.StatusOK, governanceInstallConfirmResponse{ + Status: "governance_active", + Wallet: wallet, + DeviceID: installRec.DeviceID, + EntitlementID: installRec.EntitlementID, + RuntimeVersion: installRec.RuntimeVersion, + ActivatedAt: activatedAt.Format(time.RFC3339Nano), + }) +} + +func (a *app) handleGovernanceInstallStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + walletRaw := strings.TrimSpace(r.URL.Query().Get("wallet")) + deviceID := strings.TrimSpace(r.URL.Query().Get("device_id")) + wallet, err := normalizeAddress(walletRaw) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) + return + } + + membershipStatus := "none" + if rec, err := a.store.getDesignationByAddress(r.Context(), wallet); err == nil { + membershipStatus = strings.ToLower(strings.TrimSpace(rec.MembershipStatus)) + if membershipStatus == "" { + membershipStatus = "none" + } + } + + resp := governanceInstallStatusResponse{ + Wallet: wallet, + MembershipStatus: membershipStatus, + EntitlementStatus: "unknown", + AccessClass: "unknown", + AvailabilityState: "unknown", + ActivationStatus: "not_installed", + LatestRuntimeVersion: a.cfg.GovernanceRuntimeVersion, + PolicyHash: a.cfg.GovernancePolicyHash, + } + + principal, err := a.store.getGovernancePrincipal(r.Context(), wallet) + if err == nil { + resp.OrgRootID = principal.OrgRootID + resp.PrincipalID = principal.PrincipalID + resp.PrincipalRole = principal.PrincipalRole + resp.EntitlementStatus = strings.ToLower(strings.TrimSpace(principal.EntitlementStatus)) + resp.AccessClass = strings.ToLower(strings.TrimSpace(principal.AccessClass)) + resp.AvailabilityState = strings.ToLower(strings.TrimSpace(principal.AvailabilityState)) + } else if !errors.Is(err, errNotFound) { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to load principal status") + return + } + + if deviceID != "" { + if installRec, err := a.store.getGovernanceInstallByDevice(r.Context(), wallet, deviceID); err == nil { + resp.ActivationStatus = "active" + resp.LatestRuntimeVersion = installRec.RuntimeVersion + } + } + if resp.AvailabilityState == "parked" { + resp.ActivationStatus = "blocked" + resp.Reason = "availability_parked" + } + if resp.EntitlementStatus != "" && resp.EntitlementStatus != "unknown" && resp.EntitlementStatus != "active" { + resp.ActivationStatus = "blocked" + resp.Reason = "entitlement_inactive" + } + if resp.MembershipStatus != "active" { + resp.Reason = "membership_inactive" + } + writeJSON(w, http.StatusOK, resp) +} + +func (a *app) handleGovernanceLeaseHeartbeat(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req governanceLeaseHeartbeatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body") + return + } + wallet, err := normalizeAddress(req.Wallet) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) + return + } + principal, err := a.store.getGovernancePrincipal(r.Context(), wallet) + if err != nil { + writeErrorCode(w, http.StatusForbidden, "principal_missing", "principal state missing") + return + } + if principal.OrgRootID != strings.TrimSpace(req.OrgRootID) || principal.PrincipalID != strings.TrimSpace(req.PrincipalID) { + writeErrorCode(w, http.StatusForbidden, "boundary_mismatch", "principal boundary mismatch") + return + } + if strings.ToLower(strings.TrimSpace(principal.EntitlementStatus)) != "active" { + writeErrorCode(w, http.StatusForbidden, "entitlement_inactive", "entitlement inactive") + return + } + leaseExpires := time.Now().UTC().Add(a.cfg.LeaseTTL) + principal.LeaseExpiresAt = &leaseExpires + principal.AvailabilityState = "active" + principal.UpdatedAt = time.Now().UTC() + if err := a.store.putGovernancePrincipal(r.Context(), principal); err != nil { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist lease heartbeat") + return + } + writeJSON(w, http.StatusOK, governanceLeaseHeartbeatResponse{ + Status: "lease_refreshed", + AvailabilityState: "active", + LeaseExpiresAt: leaseExpires.Format(time.RFC3339Nano), + }) +} + +func (a *app) handleGovernanceOfflineRenew(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req governanceOfflineRenewRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body") + return + } + wallet, err := normalizeAddress(req.Wallet) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) + return + } + principal, err := a.store.getGovernancePrincipal(r.Context(), wallet) + if err != nil { + writeErrorCode(w, http.StatusForbidden, "principal_missing", "principal state missing") + return + } + if principal.OrgRootID != strings.TrimSpace(req.OrgRootID) || principal.PrincipalID != strings.TrimSpace(req.PrincipalID) { + writeErrorCode(w, http.StatusForbidden, "boundary_mismatch", "principal boundary mismatch") + return + } + if strings.ToLower(strings.TrimSpace(principal.EntitlementStatus)) != "active" { + writeErrorCode(w, http.StatusForbidden, "entitlement_inactive", "entitlement inactive") + return + } + renewedUntil := time.Now().UTC().Add(a.cfg.OfflineRenewTTL) + principal.AccessClass = "sovereign" + principal.AvailabilityState = "active" + principal.LeaseExpiresAt = &renewedUntil + principal.UpdatedAt = time.Now().UTC() + if err := a.store.putGovernancePrincipal(r.Context(), principal); err != nil { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist offline renewal") + return + } + writeJSON(w, http.StatusOK, governanceOfflineRenewResponse{ + Status: "renewal_applied", + AvailabilityState: "active", + RenewedUntil: renewedUntil.Format(time.RFC3339Nano), + }) +} + +func (a *app) handleMemberChannelDeviceRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req memberChannelDeviceRegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body") + return + } + wallet, err := normalizeAddress(req.Wallet) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) + return + } + if req.ChainID != a.cfg.ChainID { + writeErrorCode(w, http.StatusBadRequest, "unsupported_chain_id", fmt.Sprintf("unsupported chain_id: %d", req.ChainID)) + return + } + req.DeviceID = strings.TrimSpace(req.DeviceID) + req.Platform = strings.ToLower(strings.TrimSpace(req.Platform)) + req.OrgRootID = strings.TrimSpace(req.OrgRootID) + req.PrincipalID = strings.TrimSpace(req.PrincipalID) + req.PrincipalRole = strings.ToLower(strings.TrimSpace(req.PrincipalRole)) + req.AppVersion = strings.TrimSpace(req.AppVersion) + req.PushProvider = strings.ToLower(strings.TrimSpace(req.PushProvider)) + req.PushToken = strings.TrimSpace(req.PushToken) + if req.DeviceID == "" || req.Platform == "" || req.OrgRootID == "" || req.PrincipalID == "" || req.PrincipalRole == "" || req.AppVersion == "" { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id, platform, org_root_id, principal_id, principal_role, app_version required") + return + } + if req.PushProvider == "" { + req.PushProvider = "none" + } + + rec, err := a.store.getDesignationByAddress(r.Context(), wallet) + if err != nil || !strings.EqualFold(strings.TrimSpace(rec.MembershipStatus), "active") { + writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership_inactive") + return + } + + now := time.Now().UTC() + channelBindingID, err := randomHex(12) + if err != nil { + writeErrorCode(w, http.StatusInternalServerError, "binding_generation_failed", "failed to create channel binding") + return + } + binding := memberChannelBindingRecord{ + ChannelBindingID: "ch_" + channelBindingID, + Wallet: wallet, + ChainID: req.ChainID, + DeviceID: req.DeviceID, + Platform: req.Platform, + OrgRootID: req.OrgRootID, + PrincipalID: req.PrincipalID, + PrincipalRole: req.PrincipalRole, + AppVersion: req.AppVersion, + PushProvider: req.PushProvider, + PushToken: req.PushToken, + Status: "active", + CreatedAt: now, + UpdatedAt: now, + } + if err := a.store.putMemberChannelBinding(r.Context(), binding); err != nil { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist channel binding") + return + } + + _ = a.seedMemberChannelEvents(r.Context(), binding) + + writeJSON(w, http.StatusOK, memberChannelDeviceRegisterResponse{ + ChannelBindingID: binding.ChannelBindingID, + Status: "active", + PollIntervalSeconds: a.cfg.MemberPollIntervalSec, + ServerTime: now.Format(time.RFC3339Nano), + }) +} + +func (a *app) handleMemberChannelDeviceUnregister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req memberChannelDeviceUnregisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body") + return + } + wallet, err := normalizeAddress(req.Wallet) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) + return + } + req.DeviceID = strings.TrimSpace(req.DeviceID) + if req.DeviceID == "" { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required") + return + } + if err := a.store.removeMemberChannelBinding(r.Context(), wallet, req.DeviceID, time.Now().UTC()); err != nil { + if errors.Is(err, errNotFound) { + writeErrorCode(w, http.StatusNotFound, "channel_binding_not_found", "device channel binding not found") + return + } + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to remove channel binding") + return + } + writeJSON(w, http.StatusOK, memberChannelDeviceUnregisterResponse{ + Status: "removed", + Wallet: wallet, + DeviceID: req.DeviceID, + }) +} + +func (a *app) handleMemberChannelEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + wallet, err := normalizeAddress(strings.TrimSpace(r.URL.Query().Get("wallet"))) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) + return + } + deviceID := strings.TrimSpace(r.URL.Query().Get("device_id")) + if deviceID == "" { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required") + return + } + limit := 25 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + parsed, parseErr := strconv.Atoi(raw) + if parseErr != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid limit") + return + } + if parsed < 1 { + parsed = 1 + } + if parsed > 100 { + parsed = 100 + } + limit = parsed + } + cursor := strings.TrimSpace(r.URL.Query().Get("cursor")) + + rec, err := a.store.getDesignationByAddress(r.Context(), wallet) + if err != nil || !strings.EqualFold(strings.TrimSpace(rec.MembershipStatus), "active") { + writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership_inactive") + return + } + binding, err := a.store.getMemberChannelBinding(r.Context(), wallet, deviceID) + if err != nil { + writeErrorCode(w, http.StatusForbidden, "channel_binding_missing", "channel binding missing") + return + } + ownerVisible := strings.EqualFold(strings.TrimSpace(binding.PrincipalRole), "org_root_owner") + events, nextCursor, err := a.store.listMemberChannelEvents(r.Context(), wallet, binding.OrgRootID, ownerVisible, cursor, limit) + if err != nil { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to list member channel events") + return + } + + out := make([]memberChannelEvent, 0, len(events)) + for _, event := range events { + payload := map[string]any{} + if strings.TrimSpace(event.PayloadJSON) != "" { + _ = json.Unmarshal([]byte(event.PayloadJSON), &payload) + } + out = append(out, memberChannelEvent{ + EventID: event.EventID, + Class: event.Class, + CreatedAt: event.CreatedAt.Format(time.RFC3339Nano), + Title: event.Title, + Body: event.Body, + DedupeKey: event.DedupeKey, + RequiresAck: event.RequiresAck, + PolicyHash: event.PolicyHash, + VisibilityScope: event.VisibilityScope, + Payload: payload, + }) + } + writeJSON(w, http.StatusOK, memberChannelEventsResponse{ + Wallet: wallet, + DeviceID: binding.DeviceID, + OrgRootID: binding.OrgRootID, + PrincipalID: binding.PrincipalID, + Events: out, + NextCursor: nextCursor, + ServerTime: time.Now().UTC().Format(time.RFC3339Nano), + }) +} + +func (a *app) handleMemberChannelEventAck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + const prefix = "/member/channel/events/" + path := strings.TrimPrefix(r.URL.Path, prefix) + if path == r.URL.Path || !strings.HasSuffix(path, "/ack") { + writeErrorCode(w, http.StatusNotFound, "not_found", "route not found") + return + } + eventID := strings.TrimSuffix(path, "/ack") + eventID = strings.TrimSpace(strings.TrimSuffix(eventID, "/")) + if eventID == "" { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "event_id required") + return + } + + var req memberChannelEventAckRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body") + return + } + wallet, err := normalizeAddress(req.Wallet) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) + return + } + deviceID := strings.TrimSpace(req.DeviceID) + if deviceID == "" { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "device_id required") + return + } + ackAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.AcknowledgedAt)) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "acknowledged_at must be RFC3339") + return + } + event, err := a.store.getMemberChannelEventByID(r.Context(), eventID) + if err != nil { + writeErrorCode(w, http.StatusNotFound, "event_not_found", "event not found") + return + } + binding, err := a.store.getMemberChannelBinding(r.Context(), wallet, deviceID) + if err != nil || !strings.EqualFold(event.Wallet, wallet) || !strings.EqualFold(event.OrgRootID, binding.OrgRootID) { + writeErrorCode(w, http.StatusForbidden, "channel_binding_missing", "channel binding missing") + return + } + if strings.EqualFold(event.VisibilityScope, "owner_admin") && !strings.EqualFold(strings.TrimSpace(binding.PrincipalRole), "org_root_owner") { + writeErrorCode(w, http.StatusForbidden, "owner_role_required", "contact_your_org_admin") + return + } + effectiveAck, err := a.store.putMemberChannelEventAck(r.Context(), eventID, wallet, deviceID, ackAt.UTC()) + if err != nil { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist acknowledgement") + return + } + writeJSON(w, http.StatusOK, memberChannelEventAckResponse{ + Status: "acknowledged", + EventID: eventID, + AcknowledgedAt: effectiveAck.Format(time.RFC3339Nano), + }) +} + +func (a *app) handleMemberChannelSupportTicket(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req memberChannelSupportTicketRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body") + return + } + wallet, err := normalizeAddress(req.Wallet) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) + return + } + req.OrgRootID = strings.TrimSpace(req.OrgRootID) + req.PrincipalID = strings.TrimSpace(req.PrincipalID) + req.Category = strings.TrimSpace(req.Category) + req.Summary = strings.TrimSpace(req.Summary) + if req.OrgRootID == "" || req.PrincipalID == "" || req.Category == "" || req.Summary == "" { + writeErrorCode(w, http.StatusBadRequest, "invalid_request", "org_root_id, principal_id, category, summary required") + return + } + + rec, err := a.store.getDesignationByAddress(r.Context(), wallet) + if err != nil || !strings.EqualFold(strings.TrimSpace(rec.MembershipStatus), "active") { + writeErrorCode(w, http.StatusForbidden, "membership_inactive", "membership_inactive") + return + } + + role := "" + if principal, principalErr := a.store.getGovernancePrincipal(r.Context(), wallet); principalErr == nil && + strings.EqualFold(strings.TrimSpace(principal.OrgRootID), req.OrgRootID) && + strings.EqualFold(strings.TrimSpace(principal.PrincipalID), req.PrincipalID) { + role = strings.ToLower(strings.TrimSpace(principal.PrincipalRole)) + } + if role == "" { + if binding, bindingErr := a.store.getMemberChannelBindingByPrincipal(r.Context(), wallet, req.OrgRootID, req.PrincipalID); bindingErr == nil { + role = strings.ToLower(strings.TrimSpace(binding.PrincipalRole)) + } + } + if role != "org_root_owner" { + writeErrorCode(w, http.StatusForbidden, "owner_role_required", "contact_your_org_admin") + return + } + + contextJSON := "{}" + if len(req.Context) > 0 { + raw, marshalErr := json.Marshal(req.Context) + if marshalErr == nil { + contextJSON = string(raw) + } + } + idRaw, err := randomHex(12) + if err != nil { + writeErrorCode(w, http.StatusInternalServerError, "ticket_generation_failed", "failed to generate ticket id") + return + } + now := time.Now().UTC() + ticket := memberChannelSupportTicketRecord{ + TicketID: "st_" + idRaw, + Wallet: wallet, + OrgRootID: req.OrgRootID, + PrincipalID: req.PrincipalID, + Category: req.Category, + Summary: req.Summary, + ContextJSON: contextJSON, + Status: "accepted", + CreatedAt: now, + } + if err := a.store.putMemberChannelSupportTicket(r.Context(), ticket); err != nil { + writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist support ticket") + return + } + + writeJSON(w, http.StatusOK, memberChannelSupportTicketResponse{ + Status: "accepted", + TicketID: ticket.TicketID, + CreatedAt: now.Format(time.RFC3339Nano), + }) +} + +func (a *app) seedMemberChannelEvents(ctx context.Context, binding memberChannelBindingRecord) error { + membershipPayload := map[string]any{ + "status": "active", + } + rawMembershipPayload, _ := json.Marshal(membershipPayload) + _ = a.store.putMemberChannelEvent(ctx, memberChannelEventRecord{ + Wallet: binding.Wallet, + OrgRootID: binding.OrgRootID, + PrincipalID: binding.PrincipalID, + Class: "membership_policy", + CreatedAt: time.Now().UTC(), + Title: "Membership active", + Body: "Your EDUT membership is active. Governance install is available when you are ready.", + DedupeKey: "membership_policy:active", + RequiresAck: true, + PolicyHash: a.cfg.GovernancePolicyHash, + PayloadJSON: string(rawMembershipPayload), + VisibilityScope: "member", + }) + + if strings.EqualFold(strings.TrimSpace(binding.PrincipalRole), "org_root_owner") { + updatePayload := map[string]any{ + "version": a.cfg.GovernanceRuntimeVersion, + "channel": a.cfg.GovernanceRolloutChannel, + } + rawUpdatePayload, _ := json.Marshal(updatePayload) + _ = a.store.putMemberChannelEvent(ctx, memberChannelEventRecord{ + Wallet: binding.Wallet, + OrgRootID: binding.OrgRootID, + PrincipalID: binding.PrincipalID, + Class: "platform_update", + CreatedAt: time.Now().UTC(), + Title: "Governance runtime available", + Body: "A deterministic governance runtime is available for this organization boundary.", + DedupeKey: "platform_update:" + a.cfg.GovernanceRuntimeVersion, + RequiresAck: true, + PolicyHash: a.cfg.GovernancePolicyHash, + PayloadJSON: string(rawUpdatePayload), + VisibilityScope: "owner_admin", + }) + } + return nil +} + +func (a *app) resolveOrCreatePrincipal(ctx context.Context, wallet, orgRootID, principalID, principalRole string) (governancePrincipalRecord, error) { + principal, err := a.store.getGovernancePrincipal(ctx, wallet) + if err == nil { + return principal, nil + } + if !errors.Is(err, errNotFound) { + return governancePrincipalRecord{}, err + } + orgRootID = strings.TrimSpace(orgRootID) + principalID = strings.TrimSpace(principalID) + principalRole = strings.TrimSpace(strings.ToLower(principalRole)) + if orgRootID == "" { + orgRootID = "org_" + wallet[2:10] + } + if principalID == "" { + principalID = "principal_" + wallet[2:10] + } + if principalRole == "" { + principalRole = "org_root_owner" + } + now := time.Now().UTC() + leaseExpires := now.Add(a.cfg.LeaseTTL) + principal = governancePrincipalRecord{ + Wallet: wallet, + OrgRootID: orgRootID, + PrincipalID: principalID, + PrincipalRole: principalRole, + EntitlementID: "gov_" + wallet[2:10], + EntitlementStatus: "active", + AccessClass: "connected", + AvailabilityState: "active", + LeaseExpiresAt: &leaseExpires, + UpdatedAt: now, + } + if err := a.store.putGovernancePrincipal(ctx, principal); err != nil { + return governancePrincipalRecord{}, err + } + return principal, nil +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeErrorCode(w, status, "request_failed", message) +} + +func writeErrorCode(w http.ResponseWriter, status int, code string, message string) { + correlationID := "req_unknown" + if rid, err := randomHex(8); err == nil { + correlationID = "req_" + rid + } + writeJSON(w, status, map[string]string{ + "error": message, + "code": strings.TrimSpace(strings.ToLower(code)), + "correlation_id": correlationID, + }) +} + +func randomHex(byteLen int) (string, error) { + buf := make([]byte, byteLen) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func newDesignationCode() (code string, displayToken string, err error) { + // 13-digit deterministic shape: unix millis + random suffix. + now := time.Now().UTC().UnixMilli() + suffix, err := randomInt(1000, 9999) + if err != nil { + return "", "", err + } + raw := fmt.Sprintf("%d%d", now, suffix) + if len(raw) > 13 { + raw = raw[len(raw)-13:] + } + if len(raw) < 13 { + raw = strings.Repeat("0", 13-len(raw)) + raw + } + if !reDigits.MatchString(raw) { + return "", "", fmt.Errorf("invalid designation code") + } + return raw, buildDisplayToken(raw), nil +} + +func buildDisplayToken(code string) string { + if len(code) < 13 { + return code + } + return code[0:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:] +} + +func randomInt(min int, max int) (int, error) { + if min >= max { + return min, nil + } + span := big.NewInt(int64(max - min + 1)) + n, err := rand.Int(rand.Reader, span) + if err != nil { + return 0, err + } + return min + int(n.Int64()), nil +} + +func isTxHash(value string) bool { + value = strings.TrimSpace(strings.ToLower(value)) + if !strings.HasPrefix(value, "0x") || len(value) != 66 { + return false + } + _, err := strconv.ParseUint(value[2:18], 16, 64) + return err == nil +} + +func logConfig(cfg Config) { + log.Printf("secret api listening on %s chain_id=%d contract=%s currency=%s amount_atomic=%s", cfg.ListenAddr, cfg.ChainID, cfg.MembershipContract, cfg.MintCurrency, cfg.MintAmountAtomic) +} diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go new file mode 100644 index 0000000..59a5afa --- /dev/null +++ b/backend/secretapi/app_test.go @@ -0,0 +1,564 @@ +package main + +import ( + "bytes" + "context" + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" +) + +func TestMembershipDistinctPayerProof(t *testing.T) { + a, cfg, cleanup := newTestApp(t) + defer cleanup() + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + payerKey := mustKey(t) + payerAddr := strings.ToLower(crypto.PubkeyToAddress(payerKey.PublicKey).Hex()) + + intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{ + Address: ownerAddr, + Origin: "https://edut.ai", + Locale: "en", + ChainID: cfg.ChainID, + }, http.StatusOK) + + issuedAt, err := time.Parse(time.RFC3339Nano, intentRes.IssuedAt) + if err != nil { + t.Fatalf("parse issued_at: %v", err) + } + td := buildTypedData(cfg, designationRecord{ + Code: intentRes.DesignationCode, + DisplayToken: intentRes.DisplayToken, + Nonce: intentRes.Nonce, + IssuedAt: issuedAt, + Origin: "https://edut.ai", + }) + sig := signTypedData(t, ownerKey, td) + + verifyRes := postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{ + IntentID: intentRes.IntentID, + Address: ownerAddr, + ChainID: cfg.ChainID, + Signature: sig, + }, http.StatusOK) + if verifyRes.Status != "signature_verified" { + t.Fatalf("unexpected verify status: %+v", verifyRes) + } + + // Distinct payer without proof must fail closed. + _ = postJSONExpect[map[string]string](t, a, "/secret/membership/quote", membershipQuoteRequest{ + DesignationCode: verifyRes.DesignationCode, + Address: ownerAddr, + ChainID: cfg.ChainID, + PayerWallet: payerAddr, + }, http.StatusForbidden) + + payerProofSig := signPersonalMessage( + t, + ownerKey, + payerProofMessage(verifyRes.DesignationCode, ownerAddr, payerAddr, cfg.ChainID), + ) + quote := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{ + DesignationCode: verifyRes.DesignationCode, + Address: ownerAddr, + ChainID: cfg.ChainID, + PayerWallet: payerAddr, + PayerProof: payerProofSig, + SponsorOrgRoot: "org_company_a", + }, http.StatusOK) + if got := strings.ToLower(strings.TrimSpace(quote.OwnerWallet)); got != ownerAddr { + t.Fatalf("owner wallet mismatch: got=%s want=%s", got, ownerAddr) + } + if got := strings.ToLower(strings.TrimSpace(quote.PayerWallet)); got != payerAddr { + t.Fatalf("payer wallet mismatch: got=%s want=%s", got, payerAddr) + } + if quote.Tx["from"] != payerAddr { + t.Fatalf("tx.from mismatch: %+v", quote.Tx) + } +} + +func TestMembershipCompanySponsorWithoutOwnerProof(t *testing.T) { + a, cfg, cleanup := newTestApp(t) + defer cleanup() + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + companyPayerKey := mustKey(t) + companyPayerAddr := strings.ToLower(crypto.PubkeyToAddress(companyPayerKey.PublicKey).Hex()) + + now := time.Now().UTC() + if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{ + Wallet: companyPayerAddr, + OrgRootID: "org_company_b", + PrincipalID: "principal_company_owner", + PrincipalRole: "org_root_owner", + EntitlementID: "gov_company_b", + EntitlementStatus: "active", + AccessClass: "connected", + AvailabilityState: "active", + UpdatedAt: now, + }); err != nil { + t.Fatalf("seed governance principal: %v", err) + } + + intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{ + Address: ownerAddr, + Origin: "https://edut.ai", + Locale: "en", + ChainID: cfg.ChainID, + }, http.StatusOK) + issuedAt, err := time.Parse(time.RFC3339Nano, intentRes.IssuedAt) + if err != nil { + t.Fatalf("parse issued_at: %v", err) + } + td := buildTypedData(cfg, designationRecord{ + Code: intentRes.DesignationCode, + DisplayToken: intentRes.DisplayToken, + Nonce: intentRes.Nonce, + IssuedAt: issuedAt, + Origin: "https://edut.ai", + }) + sig := signTypedData(t, ownerKey, td) + verifyRes := postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{ + IntentID: intentRes.IntentID, + Address: ownerAddr, + ChainID: cfg.ChainID, + Signature: sig, + }, http.StatusOK) + + quote := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{ + DesignationCode: verifyRes.DesignationCode, + Address: ownerAddr, + ChainID: cfg.ChainID, + PayerWallet: companyPayerAddr, + SponsorOrgRoot: "org_company_b", + }, http.StatusOK) + if quote.SponsorshipMode != "sponsored_company" { + t.Fatalf("expected sponsored_company mode, got %+v", quote) + } +} + +func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) { + a, cfg, cleanup := newTestApp(t) + defer cleanup() + + memberKey := mustKey(t) + memberAddr := strings.ToLower(crypto.PubkeyToAddress(memberKey.PublicKey).Hex()) + if err := seedActiveMembership(context.Background(), a.store, memberAddr); err != nil { + t.Fatalf("seed member membership: %v", err) + } + _ = postJSONExpect[map[string]string](t, a, "/governance/install/token", governanceInstallTokenRequest{ + Wallet: memberAddr, + OrgRootID: "org_root_a", + PrincipalID: "principal_member", + PrincipalRole: "workspace_member", + DeviceID: "macstudio-001", + LauncherVersion: "0.1.0", + Platform: "macos", + }, http.StatusForbidden) + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { + t.Fatalf("seed membership: %v", err) + } + + tokenRes := postJSONExpect[governanceInstallTokenResponse](t, a, "/governance/install/token", governanceInstallTokenRequest{ + Wallet: ownerAddr, + OrgRootID: "org_root_a", + PrincipalID: "principal_owner", + PrincipalRole: "org_root_owner", + DeviceID: "macstudio-001", + LauncherVersion: "0.1.0", + Platform: "macos", + }, http.StatusOK) + if tokenRes.InstallToken == "" { + t.Fatalf("missing install token") + } + + _ = postJSONExpect[map[string]string](t, a, "/governance/install/confirm", governanceInstallConfirmRequest{ + InstallToken: tokenRes.InstallToken, + Wallet: ownerAddr, + DeviceID: "macstudio-001", + EntitlementID: tokenRes.EntitlementID, + PackageHash: "sha256:wrong", + RuntimeVersion: cfg.GovernanceRuntimeVersion, + InstalledAt: time.Now().UTC().Format(time.RFC3339Nano), + }, http.StatusConflict) + + confirm := postJSONExpect[governanceInstallConfirmResponse](t, a, "/governance/install/confirm", governanceInstallConfirmRequest{ + InstallToken: tokenRes.InstallToken, + Wallet: ownerAddr, + DeviceID: "macstudio-001", + EntitlementID: tokenRes.EntitlementID, + PackageHash: cfg.GovernancePackageHash, + RuntimeVersion: cfg.GovernanceRuntimeVersion, + InstalledAt: time.Now().UTC().Format(time.RFC3339Nano), + LauncherReceiptRef: "receipt-hash-1", + }, http.StatusOK) + if confirm.Status != "governance_active" { + t.Fatalf("unexpected confirm status: %+v", confirm) + } + + statusReq := httptest.NewRequest(http.MethodGet, "/governance/install/status?wallet="+ownerAddr+"&device_id=macstudio-001", nil) + statusRec := httptest.NewRecorder() + a.routes().ServeHTTP(statusRec, statusReq) + if statusRec.Code != http.StatusOK { + t.Fatalf("status code=%d body=%s", statusRec.Code, statusRec.Body.String()) + } + var status governanceInstallStatusResponse + if err := json.Unmarshal(statusRec.Body.Bytes(), &status); err != nil { + t.Fatalf("decode status: %v", err) + } + if status.ActivationStatus != "active" { + t.Fatalf("expected active status got %+v", status) + } +} + +func TestGovernanceLeaseAndOfflineRenew(t *testing.T) { + a, _, cleanup := newTestApp(t) + defer cleanup() + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { + t.Fatalf("seed membership: %v", err) + } + + _ = postJSONExpect[governanceInstallTokenResponse](t, a, "/governance/install/token", governanceInstallTokenRequest{ + Wallet: ownerAddr, + OrgRootID: "org_root_b", + PrincipalID: "principal_owner_b", + PrincipalRole: "org_root_owner", + DeviceID: "macstudio-002", + LauncherVersion: "0.1.0", + Platform: "macos", + }, http.StatusOK) + + _ = postJSONExpect[map[string]string](t, a, "/governance/lease/heartbeat", governanceLeaseHeartbeatRequest{ + Wallet: ownerAddr, + OrgRootID: "wrong_org", + PrincipalID: "principal_owner_b", + DeviceID: "macstudio-002", + }, http.StatusForbidden) + + leaseRes := postJSONExpect[governanceLeaseHeartbeatResponse](t, a, "/governance/lease/heartbeat", governanceLeaseHeartbeatRequest{ + Wallet: ownerAddr, + OrgRootID: "org_root_b", + PrincipalID: "principal_owner_b", + DeviceID: "macstudio-002", + }, http.StatusOK) + if leaseRes.Status != "lease_refreshed" || leaseRes.AvailabilityState != "active" { + t.Fatalf("unexpected lease response: %+v", leaseRes) + } + + renewRes := postJSONExpect[governanceOfflineRenewResponse](t, a, "/governance/lease/offline-renew", governanceOfflineRenewRequest{ + Wallet: ownerAddr, + OrgRootID: "org_root_b", + PrincipalID: "principal_owner_b", + RenewalBundle: map[string]any{ + "bundle_id": "renew-1", + }, + }, http.StatusOK) + if renewRes.Status != "renewal_applied" || renewRes.AvailabilityState != "active" { + t.Fatalf("unexpected renew response: %+v", renewRes) + } +} + +func TestGovernanceInstallTokenBlockedWhenParked(t *testing.T) { + a, _, cleanup := newTestApp(t) + defer cleanup() + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { + t.Fatalf("seed membership: %v", err) + } + now := time.Now().UTC() + if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{ + Wallet: ownerAddr, + OrgRootID: "org_parked", + PrincipalID: "principal_parked", + PrincipalRole: "org_root_owner", + EntitlementID: "gov_parked", + EntitlementStatus: "active", + AccessClass: "connected", + AvailabilityState: "parked", + UpdatedAt: now, + }); err != nil { + t.Fatalf("seed principal: %v", err) + } + + _ = postJSONExpect[map[string]string](t, a, "/governance/install/token", governanceInstallTokenRequest{ + Wallet: ownerAddr, + OrgRootID: "org_parked", + PrincipalID: "principal_parked", + PrincipalRole: "org_root_owner", + DeviceID: "macstudio-parked", + LauncherVersion: "0.1.0", + Platform: "macos", + }, http.StatusForbidden) +} + +func TestMemberChannelRegisterPollAckAndUnregister(t *testing.T) { + a, _, cleanup := newTestApp(t) + defer cleanup() + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { + t.Fatalf("seed membership: %v", err) + } + + register := postJSONExpect[memberChannelDeviceRegisterResponse](t, a, "/member/channel/device/register", memberChannelDeviceRegisterRequest{ + Wallet: ownerAddr, + ChainID: 84532, + DeviceID: "desktop-01", + Platform: "desktop", + OrgRootID: "org.test.root", + PrincipalID: "human.owner", + PrincipalRole: "org_root_owner", + AppVersion: "0.1.0", + PushProvider: "none", + }, http.StatusOK) + if register.Status != "active" || register.ChannelBindingID == "" { + t.Fatalf("unexpected register response: %+v", register) + } + + events := getJSONExpect[memberChannelEventsResponse](t, a, "/member/channel/events?wallet="+ownerAddr+"&device_id=desktop-01&limit=25", http.StatusOK) + if len(events.Events) == 0 { + t.Fatalf("expected seeded events, got none") + } + first := events.Events[0] + ackTime := time.Now().UTC() + ack := postJSONExpect[memberChannelEventAckResponse](t, a, "/member/channel/events/"+first.EventID+"/ack", memberChannelEventAckRequest{ + Wallet: ownerAddr, + DeviceID: "desktop-01", + AcknowledgedAt: ackTime.Format(time.RFC3339Nano), + }, http.StatusOK) + if ack.Status != "acknowledged" || ack.EventID != first.EventID { + t.Fatalf("unexpected ack response: %+v", ack) + } + + // Idempotent ack should keep original acknowledgement timestamp. + ack2 := postJSONExpect[memberChannelEventAckResponse](t, a, "/member/channel/events/"+first.EventID+"/ack", memberChannelEventAckRequest{ + Wallet: ownerAddr, + DeviceID: "desktop-01", + AcknowledgedAt: ackTime.Add(30 * time.Second).Format(time.RFC3339Nano), + }, http.StatusOK) + if ack2.AcknowledgedAt != ack.AcknowledgedAt { + t.Fatalf("ack should be idempotent: first=%s second=%s", ack.AcknowledgedAt, ack2.AcknowledgedAt) + } + + _ = postJSONExpect[memberChannelDeviceUnregisterResponse](t, a, "/member/channel/device/unregister", memberChannelDeviceUnregisterRequest{ + Wallet: ownerAddr, + DeviceID: "desktop-01", + }, http.StatusOK) + + _ = getJSONExpect[map[string]string](t, a, "/member/channel/events?wallet="+ownerAddr+"&device_id=desktop-01&limit=10", http.StatusForbidden) +} + +func TestMemberChannelSupportTicketOwnerOnly(t *testing.T) { + a, _, cleanup := newTestApp(t) + defer cleanup() + + memberKey := mustKey(t) + memberAddr := strings.ToLower(crypto.PubkeyToAddress(memberKey.PublicKey).Hex()) + if err := seedActiveMembership(context.Background(), a.store, memberAddr); err != nil { + t.Fatalf("seed member membership: %v", err) + } + _ = postJSONExpect[memberChannelDeviceRegisterResponse](t, a, "/member/channel/device/register", memberChannelDeviceRegisterRequest{ + Wallet: memberAddr, + ChainID: 84532, + DeviceID: "desktop-member-01", + Platform: "desktop", + OrgRootID: "org.support.test", + PrincipalID: "human.member", + PrincipalRole: "workspace_member", + AppVersion: "0.1.0", + PushProvider: "none", + }, http.StatusOK) + + errResp := postJSONExpect[map[string]string](t, a, "/member/channel/support/ticket", memberChannelSupportTicketRequest{ + Wallet: memberAddr, + OrgRootID: "org.support.test", + PrincipalID: "human.member", + Category: "health_diagnostic", + Summary: "Please investigate", + }, http.StatusForbidden) + if code := errResp["code"]; code != "owner_role_required" { + t.Fatalf("expected owner_role_required, got %+v", errResp) + } + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { + t.Fatalf("seed owner membership: %v", err) + } + _ = postJSONExpect[memberChannelDeviceRegisterResponse](t, a, "/member/channel/device/register", memberChannelDeviceRegisterRequest{ + Wallet: ownerAddr, + ChainID: 84532, + DeviceID: "desktop-owner-01", + Platform: "desktop", + OrgRootID: "org.support.test", + PrincipalID: "human.owner", + PrincipalRole: "org_root_owner", + AppVersion: "0.1.0", + PushProvider: "none", + }, http.StatusOK) + + ticket := postJSONExpect[memberChannelSupportTicketResponse](t, a, "/member/channel/support/ticket", memberChannelSupportTicketRequest{ + Wallet: ownerAddr, + OrgRootID: "org.support.test", + PrincipalID: "human.owner", + Category: "admin_support", + Summary: "Need owner diagnostics.", + Context: map[string]any{ + "scope": "full", + }, + }, http.StatusOK) + if ticket.Status != "accepted" || ticket.TicketID == "" { + t.Fatalf("unexpected ticket response: %+v", ticket) + } +} + +type tWalletIntentResponse struct { + IntentID string `json:"intent_id"` + DesignationCode string `json:"designation_code"` + DisplayToken string `json:"display_token"` + Nonce string `json:"nonce"` + IssuedAt string `json:"issued_at"` +} + +type tWalletVerifyResponse struct { + Status string `json:"status"` + DesignationCode string `json:"designation_code"` +} + +func newTestApp(t *testing.T) (*app, Config, func()) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "secretapi-test.db") + cfg := loadConfig() + cfg.DBPath = dbPath + cfg.AllowedOrigin = "*" + cfg.ChainRPCURL = "" + cfg.GovernancePackageHash = "sha256:testpackage" + cfg.GovernancePolicyHash = "sha256:testpolicy" + cfg.GovernancePackageURL = "https://cdn.test/edutd.tar.gz" + cfg.GovernancePackageSig = "sig-test" + cfg.GovernanceRuntimeVersion = "0.2.0" + st, err := openStore(cfg.DBPath) + if err != nil { + t.Fatalf("open store: %v", err) + } + return newApp(cfg, st), cfg, func() { + _ = st.close() + } +} + +func seedActiveMembership(ctx context.Context, st *store, wallet string) error { + now := time.Now().UTC() + return st.putDesignation(ctx, designationRecord{ + Code: "1234567890123", + DisplayToken: "1234-5678-9012-3", + IntentID: "intent-seeded", + Nonce: "nonce-seeded", + Origin: "https://edut.ai", + Locale: "en", + Address: wallet, + ChainID: 84532, + IssuedAt: now.Add(-1 * time.Hour), + ExpiresAt: now.Add(1 * time.Hour), + VerifiedAt: &now, + MembershipStatus: "active", + MembershipTxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ActivatedAt: &now, + }) +} + +func postJSONExpect[T any](t *testing.T, a *app, path string, payload any, expectStatus int) T { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://edut.ai") + rec := httptest.NewRecorder() + a.routes().ServeHTTP(rec, req) + if rec.Code != expectStatus { + t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String()) + } + var out T + if len(rec.Body.Bytes()) == 0 { + return out + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode response: %v body=%s", err, rec.Body.String()) + } + return out +} + +func getJSONExpect[T any](t *testing.T, a *app, path string, expectStatus int) T { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Origin", "https://edut.ai") + rec := httptest.NewRecorder() + a.routes().ServeHTTP(rec, req) + if rec.Code != expectStatus { + t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String()) + } + var out T + if len(rec.Body.Bytes()) == 0 { + return out + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode response: %v body=%s", err, rec.Body.String()) + } + return out +} + +func mustKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return key +} + +func signTypedData(t *testing.T, key *ecdsa.PrivateKey, typedData apitypes.TypedData) string { + t.Helper() + hash, _, err := apitypes.TypedDataAndHash(typedData) + if err != nil { + t.Fatalf("typed data hash: %v", err) + } + sig, err := crypto.Sign(hash, key) + if err != nil { + t.Fatalf("sign typed data: %v", err) + } + return "0x" + hex.EncodeToString(sig) +} + +func signPersonalMessage(t *testing.T, key *ecdsa.PrivateKey, message string) string { + t.Helper() + hash := accounts.TextHash([]byte(message)) + sig, err := crypto.Sign(hash, key) + if err != nil { + t.Fatalf("sign personal message: %v", err) + } + return "0x" + hex.EncodeToString(sig) +} diff --git a/backend/secretapi/chain.go b/backend/secretapi/chain.go new file mode 100644 index 0000000..404e961 --- /dev/null +++ b/backend/secretapi/chain.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +const membershipABI = `[{"inputs":[{"internalType":"address","name":"recipient","type":"address"}],"name":"mintMembership","outputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"stateMutability":"payable","type":"function"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"wallet","type":"address"},{"indexed":true,"internalType":"uint256","name":"tokenId","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amountPaid","type":"uint256"},{"indexed":false,"internalType":"address","name":"currency","type":"address"}],"name":"MembershipMinted","type":"event"}]` + +func encodeMintMembershipCalldata(recipient string) (string, error) { + parsed, err := abi.JSON(strings.NewReader(membershipABI)) + if err != nil { + return "", fmt.Errorf("parse membership abi: %w", err) + } + address := common.HexToAddress(recipient) + data, err := parsed.Pack("mintMembership", address) + if err != nil { + return "", fmt.Errorf("pack mint calldata: %w", err) + } + return "0x" + common.Bytes2Hex(data), nil +} + +func verifyMintedOnChain(ctx context.Context, cfg Config, txHash string, expectedWallet string) error { + if strings.TrimSpace(cfg.ChainRPCURL) == "" { + return nil + } + + client, err := ethclient.DialContext(ctx, cfg.ChainRPCURL) + if err != nil { + return fmt.Errorf("dial chain rpc: %w", err) + } + defer client.Close() + + hash := common.HexToHash(txHash) + receipt, err := client.TransactionReceipt(ctx, hash) + if err != nil { + return fmt.Errorf("read tx receipt: %w", err) + } + if receipt == nil { + return fmt.Errorf("tx receipt not found") + } + if receipt.Status != types.ReceiptStatusSuccessful { + return fmt.Errorf("tx failed status=%d", receipt.Status) + } + + parsed, err := abi.JSON(strings.NewReader(membershipABI)) + if err != nil { + return fmt.Errorf("parse membership abi: %w", err) + } + mintedEvent := parsed.Events["MembershipMinted"] + expectedWallet = strings.ToLower(common.HexToAddress(expectedWallet).Hex()) + + for _, lg := range receipt.Logs { + if strings.ToLower(lg.Address.Hex()) != strings.ToLower(common.HexToAddress(cfg.MembershipContract).Hex()) { + continue + } + if len(lg.Topics) == 0 || lg.Topics[0] != mintedEvent.ID { + continue + } + if len(lg.Topics) < 2 { + continue + } + wallet := common.HexToAddress(lg.Topics[1].Hex()).Hex() + if strings.ToLower(wallet) == expectedWallet { + return nil + } + } + return fmt.Errorf("membership mint event not found for wallet") +} diff --git a/backend/secretapi/config.go b/backend/secretapi/config.go new file mode 100644 index 0000000..20557ca --- /dev/null +++ b/backend/secretapi/config.go @@ -0,0 +1,83 @@ +package main + +import ( + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + ListenAddr string + DBPath string + AllowedOrigin string + MemberPollIntervalSec int + IntentTTL time.Duration + QuoteTTL time.Duration + InstallTokenTTL time.Duration + LeaseTTL time.Duration + OfflineRenewTTL time.Duration + ChainID int64 + DomainName string + VerifyingContract string + MembershipContract string + MintCurrency string + MintAmountAtomic string + MintDecimals int + ChainRPCURL string + GovernanceRuntimeVersion string + GovernancePackageURL string + GovernancePackageHash string + GovernancePackageSig string + GovernanceSignerKeyID string + GovernancePolicyHash string + GovernanceRolloutChannel string +} + +func loadConfig() Config { + return Config{ + ListenAddr: env("SECRET_API_LISTEN_ADDR", ":8080"), + DBPath: env("SECRET_API_DB_PATH", "./secret.db"), + AllowedOrigin: env("SECRET_API_ALLOWED_ORIGIN", "https://edut.ai"), + 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, + InstallTokenTTL: time.Duration(envInt("SECRET_API_INSTALL_TOKEN_TTL_SECONDS", 900)) * time.Second, + LeaseTTL: time.Duration(envInt("SECRET_API_LEASE_TTL_SECONDS", 3600)) * time.Second, + OfflineRenewTTL: time.Duration(envInt("SECRET_API_OFFLINE_RENEW_TTL_SECONDS", 2592000)) * time.Second, + ChainID: int64(envInt("SECRET_API_CHAIN_ID", 84532)), + DomainName: env("SECRET_API_DOMAIN_NAME", "EDUT Designation"), + VerifyingContract: strings.ToLower(env("SECRET_API_VERIFYING_CONTRACT", "0x0000000000000000000000000000000000000000")), + MembershipContract: strings.ToLower(env("SECRET_API_MEMBERSHIP_CONTRACT", "0x0000000000000000000000000000000000000000")), + MintCurrency: strings.ToUpper(env("SECRET_API_MINT_CURRENCY", "ETH")), + MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "5000000000000000"), + MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 18), + ChainRPCURL: env("SECRET_API_CHAIN_RPC_URL", ""), + GovernanceRuntimeVersion: env("SECRET_API_GOV_RUNTIME_VERSION", "0.1.0"), + GovernancePackageURL: env("SECRET_API_GOV_PACKAGE_URL", "https://cdn.edut.ai/governance/edutd-0.1.0.tar.gz"), + GovernancePackageHash: strings.ToLower(env("SECRET_API_GOV_PACKAGE_HASH", "sha256:pending")), + GovernancePackageSig: env("SECRET_API_GOV_PACKAGE_SIGNATURE", "pending"), + GovernanceSignerKeyID: env("SECRET_API_GOV_SIGNER_KEY_ID", "edut-signer-1"), + GovernancePolicyHash: strings.ToLower(env("SECRET_API_GOV_POLICY_HASH", "sha256:pending")), + GovernanceRolloutChannel: strings.ToLower(env("SECRET_API_GOV_ROLLOUT_CHANNEL", "stable")), + } +} + +func env(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func envInt(key string, fallback int) int { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + value, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return value +} diff --git a/backend/secretapi/crypto.go b/backend/secretapi/crypto.go new file mode 100644 index 0000000..3f7939f --- /dev/null +++ b/backend/secretapi/crypto.go @@ -0,0 +1,132 @@ +package main + +import ( + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + ethmath "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" +) + +func buildTypedData(cfg Config, rec designationRecord) apitypes.TypedData { + return apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": []apitypes.Type{ + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + "DesignationIntent": []apitypes.Type{ + {Name: "designationCode", Type: "string"}, + {Name: "designationToken", Type: "string"}, + {Name: "nonce", Type: "string"}, + {Name: "issuedAt", Type: "string"}, + {Name: "origin", Type: "string"}, + }, + }, + PrimaryType: "DesignationIntent", + Domain: apitypes.TypedDataDomain{ + Name: cfg.DomainName, + Version: "1", + ChainId: mathHexOrDecimal256(cfg.ChainID), + VerifyingContract: cfg.VerifyingContract, + }, + Message: apitypes.TypedDataMessage{ + "designationCode": rec.Code, + "designationToken": rec.DisplayToken, + "nonce": rec.Nonce, + "issuedAt": rec.IssuedAt.UTC().Format(time.RFC3339Nano), + "origin": rec.Origin, + }, + } +} + +func recoverSignerAddress(typedData apitypes.TypedData, signatureHex string) (string, error) { + dataHash, _, err := apitypes.TypedDataAndHash(typedData) + if err != nil { + return "", fmt.Errorf("typed data hash: %w", err) + } + + signatureHex = strings.TrimPrefix(strings.TrimSpace(signatureHex), "0x") + sig, err := hex.DecodeString(signatureHex) + if err != nil { + return "", fmt.Errorf("decode signature: %w", err) + } + if len(sig) != 65 { + return "", fmt.Errorf("invalid signature length: %d", len(sig)) + } + if sig[64] >= 27 { + sig[64] -= 27 + } + + pubKey, err := crypto.SigToPub(dataHash, sig) + if err != nil { + return "", fmt.Errorf("recover pubkey: %w", err) + } + return strings.ToLower(crypto.PubkeyToAddress(*pubKey).Hex()), nil +} + +func normalizeAddress(address string) (string, error) { + address = strings.TrimSpace(strings.ToLower(address)) + if !common.IsHexAddress(address) { + return "", fmt.Errorf("invalid wallet address") + } + return strings.ToLower(common.HexToAddress(address).Hex()), nil +} + +func payerProofMessage(designationCode, ownerWallet, payerWallet string, chainID int64) string { + return fmt.Sprintf( + "EDUT-PAYER-AUTH:%s:%s:%s:%d", + strings.TrimSpace(designationCode), + strings.ToLower(strings.TrimSpace(ownerWallet)), + strings.ToLower(strings.TrimSpace(payerWallet)), + chainID, + ) +} + +func recoverPersonalSignAddress(message string, signatureHex string) (string, error) { + signatureHex = strings.TrimPrefix(strings.TrimSpace(signatureHex), "0x") + sig, err := hex.DecodeString(signatureHex) + if err != nil { + return "", fmt.Errorf("decode signature: %w", err) + } + if len(sig) != 65 { + return "", fmt.Errorf("invalid signature length: %d", len(sig)) + } + if sig[64] >= 27 { + sig[64] -= 27 + } + hash := accounts.TextHash([]byte(message)) + pubKey, err := crypto.SigToPub(hash, sig) + if err != nil { + return "", fmt.Errorf("recover pubkey: %w", err) + } + return strings.ToLower(crypto.PubkeyToAddress(*pubKey).Hex()), nil +} + +func verifyDistinctPayerProof(designationCode, ownerWallet, payerWallet string, chainID int64, signatureHex string) error { + msg := payerProofMessage(designationCode, ownerWallet, payerWallet, chainID) + recovered, err := recoverPersonalSignAddress(msg, signatureHex) + if err != nil { + return err + } + owner, err := normalizeAddress(ownerWallet) + if err != nil { + return err + } + if recovered != owner { + return fmt.Errorf("ownership proof signer mismatch") + } + return nil +} + +func mathHexOrDecimal256(v int64) *ethmath.HexOrDecimal256 { + out := ethmath.NewHexOrDecimal256(v) + return out +} diff --git a/backend/secretapi/deploy/secretapi.service b/backend/secretapi/deploy/secretapi.service new file mode 100644 index 0000000..27053eb --- /dev/null +++ b/backend/secretapi/deploy/secretapi.service @@ -0,0 +1,23 @@ +[Unit] +Description=EDUT Secret API backend +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=edut +Group=edut +WorkingDirectory=/opt/edut/secretapi +EnvironmentFile=/etc/edut/secretapi.env +ExecStart=/opt/edut/secretapi/secretapi +Restart=always +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/edut + +[Install] +WantedBy=multi-user.target + diff --git a/backend/secretapi/go.mod b/backend/secretapi/go.mod new file mode 100644 index 0000000..3f22c4f --- /dev/null +++ b/backend/secretapi/go.mod @@ -0,0 +1,39 @@ +module edut.ai/web/secretapi + +go 1.26.0 + +require ( + github.com/ethereum/go-ethereum v1.16.5 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/StackExchange/wmi v1.2.1 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect + github.com/consensys/gnark-crypto v0.18.0 // indirect + github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect + github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.3 // indirect + github.com/ethereum/go-verkle v0.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/backend/secretapi/go.sum b/backend/secretapi/go.sum new file mode 100644 index 0000000..8c491f1 --- /dev/null +++ b/backend/secretapi/go.sum @@ -0,0 +1,67 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/consensys/gnark-crypto v0.18.0 h1:vIye/FqI50VeAr0B3dx+YjeIvmc3LWz4yEfbWBpTUf0= +github.com/consensys/gnark-crypto v0.18.0/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= +github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= +github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ethereum/c-kzg-4844/v2 v2.1.3 h1:DQ21UU0VSsuGy8+pcMJHDS0CV1bKmJmxsJYK8l3MiLU= +github.com/ethereum/c-kzg-4844/v2 v2.1.3/go.mod h1:fyNcYI/yAuLWJxf4uzVtS8VDKeoAaRM8G/+ADz/pRdA= +github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qvQDQpe0= +github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok= +github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= +github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw= +github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= diff --git a/backend/secretapi/main.go b/backend/secretapi/main.go new file mode 100644 index 0000000..caffce8 --- /dev/null +++ b/backend/secretapi/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "net/http" +) + +func main() { + cfg := loadConfig() + logConfig(cfg) + + st, err := openStore(cfg.DBPath) + if err != nil { + log.Fatalf("open store: %v", err) + } + defer func() { + if err := st.close(); err != nil { + log.Printf("store close warning: %v", err) + } + }() + + handler := newApp(cfg, st).routes() + server := &http.Server{ + Addr: cfg.ListenAddr, + Handler: handler, + } + + log.Printf("secretapi ready addr=%s", cfg.ListenAddr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } +} diff --git a/backend/secretapi/models.go b/backend/secretapi/models.go new file mode 100644 index 0000000..8b7de2a --- /dev/null +++ b/backend/secretapi/models.go @@ -0,0 +1,383 @@ +package main + +import "time" + +type walletIntentRequest struct { + Address string `json:"address"` + Origin string `json:"origin"` + Locale string `json:"locale"` + ChainID int64 `json:"chain_id"` +} + +type walletIntentResponse struct { + IntentID string `json:"intent_id"` + DesignationCode string `json:"designation_code"` + DisplayToken string `json:"display_token"` + Nonce string `json:"nonce"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` + DomainName string `json:"domain_name"` + ChainID int64 `json:"chain_id"` + VerifyingContract string `json:"verifying_contract"` +} + +type walletVerifyRequest struct { + IntentID string `json:"intent_id"` + Address string `json:"address"` + ChainID int64 `json:"chain_id"` + Signature string `json:"signature"` +} + +type walletVerifyResponse struct { + Status string `json:"status"` + DesignationCode string `json:"designation_code"` + DisplayToken string `json:"display_token"` + VerifiedAt string `json:"verified_at"` +} + +type membershipQuoteRequest struct { + DesignationCode string `json:"designation_code"` + Address string `json:"address"` + ChainID int64 `json:"chain_id"` + PayerWallet string `json:"payer_wallet,omitempty"` + PayerProof string `json:"payer_proof,omitempty"` + SponsorOrgRoot string `json:"sponsor_org_root_id,omitempty"` +} + +type membershipQuoteResponse struct { + QuoteID string `json:"quote_id"` + ChainID int64 `json:"chain_id"` + Currency string `json:"currency"` + AmountAtomic string `json:"amount_atomic"` + Decimals int `json:"decimals"` + Deadline string `json:"deadline"` + ContractAddress string `json:"contract_address"` + Method string `json:"method"` + Calldata string `json:"calldata"` + Value string `json:"value"` + OwnerWallet string `json:"owner_wallet,omitempty"` + PayerWallet string `json:"payer_wallet,omitempty"` + SponsorshipMode string `json:"sponsorship_mode,omitempty"` + SponsorOrgRoot string `json:"sponsor_org_root_id,omitempty"` + Tx map[string]any `json:"tx"` +} + +type membershipConfirmRequest struct { + DesignationCode string `json:"designation_code"` + QuoteID string `json:"quote_id"` + TxHash string `json:"tx_hash"` + Address string `json:"address"` + ChainID int64 `json:"chain_id"` +} + +type membershipConfirmResponse struct { + Status string `json:"status"` + DesignationCode string `json:"designation_code"` + DisplayToken string `json:"display_token"` + TxHash string `json:"tx_hash"` + ActivatedAt string `json:"activated_at"` +} + +type membershipStatusResponse struct { + Status string `json:"status"` + Wallet string `json:"wallet,omitempty"` + DesignationCode string `json:"designation_code,omitempty"` +} + +type designationRecord struct { + Code string + DisplayToken string + IntentID string + Nonce string + Origin string + Locale string + Address string + ChainID int64 + IssuedAt time.Time + ExpiresAt time.Time + VerifiedAt *time.Time + MembershipStatus string + MembershipTxHash string + ActivatedAt *time.Time +} + +type quoteRecord struct { + QuoteID string + DesignationCode string + Address string // ownership wallet + PayerAddress string + ChainID int64 + Currency string + AmountAtomic string + Decimals int + ContractAddress string + Method string + Calldata string + ValueHex string + CreatedAt time.Time + ExpiresAt time.Time + ConfirmedAt *time.Time + ConfirmedTxHash string + SponsorshipMode string + SponsorOrgRootID string +} + +type governanceInstallTokenRequest struct { + Wallet string `json:"wallet"` + OrgRootID string `json:"org_root_id,omitempty"` + PrincipalID string `json:"principal_id,omitempty"` + PrincipalRole string `json:"principal_role,omitempty"` + DeviceID string `json:"device_id"` + LauncherVersion string `json:"launcher_version"` + Platform string `json:"platform"` + CurrentRuntimeVersion string `json:"current_runtime_version,omitempty"` +} + +type governancePackage struct { + RuntimeVersion string `json:"runtime_version"` + PackageURL string `json:"package_url"` + PackageHash string `json:"package_hash"` + Signature string `json:"signature"` + SignerKeyID string `json:"signer_key_id"` + PolicyHash string `json:"policy_hash"` + RolloutChannel string `json:"rollout_channel"` +} + +type governanceInstallTokenResponse struct { + InstallToken string `json:"install_token"` + InstallTokenExpiresAt string `json:"install_token_expires_at"` + Wallet string `json:"wallet"` + EntitlementID string `json:"entitlement_id"` + Package governancePackage `json:"package"` +} + +type governanceInstallConfirmRequest struct { + InstallToken string `json:"install_token"` + Wallet string `json:"wallet"` + DeviceID string `json:"device_id"` + EntitlementID string `json:"entitlement_id"` + PackageHash string `json:"package_hash"` + RuntimeVersion string `json:"runtime_version"` + InstalledAt string `json:"installed_at"` + LauncherReceiptRef string `json:"launcher_receipt_hash,omitempty"` +} + +type governanceInstallConfirmResponse struct { + Status string `json:"status"` + Wallet string `json:"wallet"` + DeviceID string `json:"device_id"` + EntitlementID string `json:"entitlement_id"` + RuntimeVersion string `json:"runtime_version"` + ActivatedAt string `json:"activated_at"` +} + +type governanceInstallStatusResponse struct { + Wallet string `json:"wallet"` + OrgRootID string `json:"org_root_id,omitempty"` + PrincipalID string `json:"principal_id,omitempty"` + PrincipalRole string `json:"principal_role,omitempty"` + MembershipStatus string `json:"membership_status"` + EntitlementStatus string `json:"entitlement_status"` + AccessClass string `json:"access_class"` + AvailabilityState string `json:"availability_state"` + ActivationStatus string `json:"activation_status"` + LatestRuntimeVersion string `json:"latest_runtime_version,omitempty"` + PolicyHash string `json:"policy_hash,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type governanceLeaseHeartbeatRequest struct { + Wallet string `json:"wallet"` + OrgRootID string `json:"org_root_id"` + PrincipalID string `json:"principal_id"` + DeviceID string `json:"device_id"` +} + +type governanceLeaseHeartbeatResponse struct { + Status string `json:"status"` + AvailabilityState string `json:"availability_state"` + LeaseExpiresAt string `json:"lease_expires_at"` +} + +type governanceOfflineRenewRequest struct { + Wallet string `json:"wallet"` + OrgRootID string `json:"org_root_id"` + PrincipalID string `json:"principal_id"` + RenewalBundle map[string]any `json:"renewal_bundle"` +} + +type governanceOfflineRenewResponse struct { + Status string `json:"status"` + AvailabilityState string `json:"availability_state"` + RenewedUntil string `json:"renewed_until"` +} + +type memberChannelDeviceRegisterRequest struct { + Wallet string `json:"wallet"` + ChainID int64 `json:"chain_id"` + DeviceID string `json:"device_id"` + Platform string `json:"platform"` + OrgRootID string `json:"org_root_id"` + PrincipalID string `json:"principal_id"` + PrincipalRole string `json:"principal_role"` + AppVersion string `json:"app_version"` + PushProvider string `json:"push_provider,omitempty"` + PushToken string `json:"push_token,omitempty"` +} + +type memberChannelDeviceRegisterResponse struct { + ChannelBindingID string `json:"channel_binding_id"` + Status string `json:"status"` + PollIntervalSeconds int `json:"poll_interval_seconds"` + ServerTime string `json:"server_time"` +} + +type memberChannelDeviceUnregisterRequest struct { + Wallet string `json:"wallet"` + DeviceID string `json:"device_id"` +} + +type memberChannelDeviceUnregisterResponse struct { + Status string `json:"status"` + Wallet string `json:"wallet"` + DeviceID string `json:"device_id"` +} + +type memberChannelEventsResponse struct { + Wallet string `json:"wallet"` + DeviceID string `json:"device_id"` + OrgRootID string `json:"org_root_id"` + PrincipalID string `json:"principal_id"` + Events []memberChannelEvent `json:"events"` + NextCursor string `json:"next_cursor"` + ServerTime string `json:"server_time"` +} + +type memberChannelEvent struct { + EventID string `json:"event_id"` + Class string `json:"class"` + CreatedAt string `json:"created_at"` + Title string `json:"title"` + Body string `json:"body"` + DedupeKey string `json:"dedupe_key"` + RequiresAck bool `json:"requires_ack"` + PolicyHash string `json:"policy_hash"` + VisibilityScope string `json:"visibility_scope"` + Payload map[string]any `json:"payload,omitempty"` +} + +type memberChannelEventAckRequest struct { + Wallet string `json:"wallet"` + DeviceID string `json:"device_id"` + AcknowledgedAt string `json:"acknowledged_at"` +} + +type memberChannelEventAckResponse struct { + Status string `json:"status"` + EventID string `json:"event_id"` + AcknowledgedAt string `json:"acknowledged_at"` +} + +type memberChannelSupportTicketRequest struct { + Wallet string `json:"wallet"` + OrgRootID string `json:"org_root_id"` + PrincipalID string `json:"principal_id"` + Category string `json:"category"` + Summary string `json:"summary"` + Context map[string]any `json:"context,omitempty"` +} + +type memberChannelSupportTicketResponse struct { + Status string `json:"status"` + TicketID string `json:"ticket_id"` + CreatedAt string `json:"created_at"` +} + +type governancePrincipalRecord struct { + Wallet string + OrgRootID string + PrincipalID string + PrincipalRole string + EntitlementID string + EntitlementStatus string + AccessClass string + AvailabilityState string + LeaseExpiresAt *time.Time + UpdatedAt time.Time +} + +type governanceInstallTokenRecord struct { + InstallToken string + Wallet string + OrgRootID string + PrincipalID string + PrincipalRole string + DeviceID string + EntitlementID string + PackageHash string + RuntimeVersion string + PolicyHash string + IssuedAt time.Time + ExpiresAt time.Time + ConsumedAt *time.Time +} + +type governanceInstallRecord struct { + InstallToken string + Wallet string + DeviceID string + EntitlementID string + RuntimeVersion string + PackageHash string + PolicyHash string + LauncherReceiptRef string + InstalledAt time.Time + ActivatedAt time.Time +} + +type memberChannelBindingRecord struct { + ChannelBindingID string + Wallet string + ChainID int64 + DeviceID string + Platform string + OrgRootID string + PrincipalID string + PrincipalRole string + AppVersion string + PushProvider string + PushToken string + Status string + CreatedAt time.Time + UpdatedAt time.Time + RemovedAt *time.Time +} + +type memberChannelEventRecord struct { + Seq int64 + EventID string + Wallet string + OrgRootID string + PrincipalID string + Class string + CreatedAt time.Time + Title string + Body string + DedupeKey string + RequiresAck bool + PolicyHash string + PayloadJSON string + VisibilityScope string +} + +type memberChannelSupportTicketRecord struct { + TicketID string + Wallet string + OrgRootID string + PrincipalID string + Category string + Summary string + ContextJSON string + Status string + CreatedAt time.Time +} diff --git a/backend/secretapi/store.go b/backend/secretapi/store.go new file mode 100644 index 0000000..b137ea7 --- /dev/null +++ b/backend/secretapi/store.go @@ -0,0 +1,788 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +var ( + errNotFound = errors.New("record not found") +) + +type store struct { + db *sql.DB +} + +func openStore(path string) (*store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + s := &store{db: db} + if err := s.migrate(context.Background()); err != nil { + _ = db.Close() + return nil, err + } + return s, nil +} + +func (s *store) close() error { + return s.db.Close() +} + +func (s *store) migrate(ctx context.Context) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS designations ( + code TEXT PRIMARY KEY, + display_token TEXT NOT NULL, + intent_id TEXT NOT NULL UNIQUE, + nonce TEXT NOT NULL, + origin TEXT NOT NULL, + locale TEXT NOT NULL, + address TEXT NOT NULL, + chain_id INTEGER NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + verified_at TEXT, + membership_status TEXT NOT NULL DEFAULT 'none', + membership_tx_hash TEXT, + activated_at TEXT + );`, + `CREATE INDEX IF NOT EXISTS idx_designations_intent ON designations(intent_id);`, + `CREATE INDEX IF NOT EXISTS idx_designations_address ON designations(address);`, + `CREATE TABLE IF NOT EXISTS quotes ( + quote_id TEXT PRIMARY KEY, + designation_code TEXT NOT NULL, + address TEXT NOT NULL, + payer_address TEXT, + chain_id INTEGER NOT NULL, + currency TEXT NOT NULL, + amount_atomic TEXT NOT NULL, + decimals INTEGER NOT NULL, + contract_address TEXT NOT NULL, + method TEXT NOT NULL, + calldata TEXT NOT NULL, + value_hex TEXT NOT NULL, + sponsorship_mode TEXT NOT NULL DEFAULT 'self', + sponsor_org_root_id TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + confirmed_at TEXT, + confirmed_tx_hash TEXT, + FOREIGN KEY(designation_code) REFERENCES designations(code) + );`, + `CREATE INDEX IF NOT EXISTS idx_quotes_designation ON quotes(designation_code);`, + `CREATE TABLE IF NOT EXISTS governance_principals ( + wallet TEXT PRIMARY KEY, + org_root_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + principal_role TEXT NOT NULL, + entitlement_id TEXT NOT NULL, + entitlement_status TEXT NOT NULL, + access_class TEXT NOT NULL, + availability_state TEXT NOT NULL, + lease_expires_at TEXT, + updated_at TEXT NOT NULL + );`, + `CREATE INDEX IF NOT EXISTS idx_governance_principals_org_root ON governance_principals(org_root_id);`, + `CREATE TABLE IF NOT EXISTS governance_install_tokens ( + install_token TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + org_root_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + principal_role TEXT NOT NULL, + device_id TEXT NOT NULL, + entitlement_id TEXT NOT NULL, + package_hash TEXT NOT NULL, + runtime_version TEXT NOT NULL, + policy_hash TEXT NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT + );`, + `CREATE INDEX IF NOT EXISTS idx_gov_install_tokens_wallet ON governance_install_tokens(wallet);`, + `CREATE INDEX IF NOT EXISTS idx_gov_install_tokens_device ON governance_install_tokens(device_id);`, + `CREATE TABLE IF NOT EXISTS governance_installs ( + install_token TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + device_id TEXT NOT NULL, + entitlement_id TEXT NOT NULL, + runtime_version TEXT NOT NULL, + package_hash TEXT NOT NULL, + policy_hash TEXT NOT NULL, + launcher_receipt_ref TEXT, + installed_at TEXT NOT NULL, + activated_at TEXT NOT NULL + );`, + `CREATE INDEX IF NOT EXISTS idx_governance_installs_wallet ON governance_installs(wallet);`, + `CREATE INDEX IF NOT EXISTS idx_governance_installs_device ON governance_installs(device_id);`, + `CREATE TABLE IF NOT EXISTS member_channel_bindings ( + channel_binding_id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + chain_id INTEGER NOT NULL, + device_id TEXT NOT NULL, + platform TEXT NOT NULL, + org_root_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + principal_role TEXT NOT NULL, + app_version TEXT NOT NULL, + push_provider TEXT NOT NULL, + push_token TEXT, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + removed_at TEXT, + UNIQUE(wallet, device_id) + );`, + `CREATE INDEX IF NOT EXISTS idx_member_channel_bindings_wallet ON member_channel_bindings(wallet);`, + `CREATE INDEX IF NOT EXISTS idx_member_channel_bindings_org ON member_channel_bindings(org_root_id);`, + `CREATE TABLE IF NOT EXISTS member_channel_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + wallet TEXT NOT NULL, + org_root_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + class TEXT NOT NULL, + created_at TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + dedupe_key TEXT NOT NULL, + requires_ack INTEGER NOT NULL DEFAULT 1, + policy_hash TEXT NOT NULL, + payload_json TEXT NOT NULL, + visibility_scope TEXT NOT NULL, + UNIQUE(wallet, org_root_id, dedupe_key) + );`, + `CREATE INDEX IF NOT EXISTS idx_member_channel_events_wallet_seq ON member_channel_events(wallet, seq);`, + `CREATE INDEX IF NOT EXISTS idx_member_channel_events_org_seq ON member_channel_events(org_root_id, seq);`, + `CREATE TABLE IF NOT EXISTS member_channel_event_acks ( + event_id TEXT NOT NULL, + wallet TEXT NOT NULL, + device_id TEXT NOT NULL, + acknowledged_at TEXT NOT NULL, + PRIMARY KEY(event_id, wallet, device_id) + );`, + `CREATE TABLE IF NOT EXISTS member_support_tickets ( + ticket_id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + org_root_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + category TEXT NOT NULL, + summary TEXT NOT NULL, + context_json TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL + );`, + `CREATE INDEX IF NOT EXISTS idx_member_support_tickets_wallet ON member_support_tickets(wallet);`, + } + for _, stmt := range statements { + if _, err := s.db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("migrate: %w", err) + } + } + // Backward-compatible column adds for already-initialized local DBs. + if err := s.ensureColumn(ctx, "quotes", "payer_address", "TEXT"); err != nil { + return err + } + if err := s.ensureColumn(ctx, "quotes", "sponsorship_mode", "TEXT NOT NULL DEFAULT 'self'"); err != nil { + return err + } + if err := s.ensureColumn(ctx, "quotes", "sponsor_org_root_id", "TEXT"); err != nil { + return err + } + return nil +} + +func (s *store) ensureColumn(ctx context.Context, table, column, columnDef string) error { + rows, err := s.db.QueryContext(ctx, "PRAGMA table_info("+table+")") + if err != nil { + return fmt.Errorf("migrate table_info %s: %w", table, err) + } + defer rows.Close() + for rows.Next() { + var ( + cid int + name string + ctype string + notNull int + defaultVal sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬Null, &defaultVal, &pk); err != nil { + return fmt.Errorf("migrate scan table_info %s: %w", table, err) + } + if strings.EqualFold(name, column) { + return nil + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("migrate iterate table_info %s: %w", table, err) + } + if _, err := s.db.ExecContext(ctx, "ALTER TABLE "+table+" ADD COLUMN "+column+" "+columnDef); err != nil { + return fmt.Errorf("migrate add column %s.%s: %w", table, column, err) + } + return nil +} + +func (s *store) putDesignation(ctx context.Context, rec designationRecord) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO designations ( + code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(code) DO UPDATE SET + display_token=excluded.display_token, + intent_id=excluded.intent_id, + nonce=excluded.nonce, + origin=excluded.origin, + locale=excluded.locale, + address=excluded.address, + chain_id=excluded.chain_id, + issued_at=excluded.issued_at, + expires_at=excluded.expires_at, + verified_at=excluded.verified_at, + membership_status=excluded.membership_status, + membership_tx_hash=excluded.membership_tx_hash, + activated_at=excluded.activated_at + `, rec.Code, rec.DisplayToken, rec.IntentID, rec.Nonce, rec.Origin, rec.Locale, rec.Address, rec.ChainID, rec.IssuedAt.Format(time.RFC3339Nano), rec.ExpiresAt.Format(time.RFC3339Nano), formatNullableTime(rec.VerifiedAt), strings.ToLower(rec.MembershipStatus), nullableString(rec.MembershipTxHash), formatNullableTime(rec.ActivatedAt)) + return err +} + +func (s *store) getDesignationByIntent(ctx context.Context, intentID string) (designationRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at + FROM designations + WHERE intent_id = ? + `, intentID) + return scanDesignation(row) +} + +func (s *store) getDesignationByCode(ctx context.Context, code string) (designationRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at + FROM designations + WHERE code = ? + `, code) + return scanDesignation(row) +} + +func (s *store) getDesignationByAddress(ctx context.Context, address string) (designationRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at + FROM designations + WHERE address = ? + ORDER BY issued_at DESC + LIMIT 1 + `, strings.ToLower(strings.TrimSpace(address))) + return scanDesignation(row) +} + +func scanDesignation(row interface{ Scan(dest ...any) error }) (designationRecord, error) { + var rec designationRecord + var issued, expires, verified, activated sql.NullString + var membershipTx sql.NullString + err := row.Scan(&rec.Code, &rec.DisplayToken, &rec.IntentID, &rec.Nonce, &rec.Origin, &rec.Locale, &rec.Address, &rec.ChainID, &issued, &expires, &verified, &rec.MembershipStatus, &membershipTx, &activated) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return designationRecord{}, errNotFound + } + return designationRecord{}, err + } + rec.IssuedAt = parseRFC3339Nullable(issued) + rec.ExpiresAt = parseRFC3339Nullable(expires) + rec.VerifiedAt = parseRFC3339Ptr(verified) + rec.MembershipTxHash = membershipTx.String + rec.ActivatedAt = parseRFC3339Ptr(activated) + return rec, nil +} + +func (s *store) putQuote(ctx context.Context, quote quoteRecord) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO quotes ( + quote_id, designation_code, address, payer_address, chain_id, currency, amount_atomic, decimals, contract_address, method, calldata, value_hex, sponsorship_mode, sponsor_org_root_id, created_at, expires_at, confirmed_at, confirmed_tx_hash + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(quote_id) DO UPDATE SET + designation_code=excluded.designation_code, + address=excluded.address, + payer_address=excluded.payer_address, + chain_id=excluded.chain_id, + currency=excluded.currency, + amount_atomic=excluded.amount_atomic, + decimals=excluded.decimals, + contract_address=excluded.contract_address, + method=excluded.method, + calldata=excluded.calldata, + value_hex=excluded.value_hex, + sponsorship_mode=excluded.sponsorship_mode, + sponsor_org_root_id=excluded.sponsor_org_root_id, + created_at=excluded.created_at, + expires_at=excluded.expires_at, + confirmed_at=excluded.confirmed_at, + confirmed_tx_hash=excluded.confirmed_tx_hash + `, quote.QuoteID, quote.DesignationCode, quote.Address, nullableString(quote.PayerAddress), quote.ChainID, quote.Currency, quote.AmountAtomic, quote.Decimals, quote.ContractAddress, quote.Method, quote.Calldata, quote.ValueHex, strings.ToLower(strings.TrimSpace(quote.SponsorshipMode)), nullableString(quote.SponsorOrgRootID), quote.CreatedAt.Format(time.RFC3339Nano), quote.ExpiresAt.Format(time.RFC3339Nano), formatNullableTime(quote.ConfirmedAt), nullableString(quote.ConfirmedTxHash)) + return err +} + +func (s *store) getQuote(ctx context.Context, quoteID string) (quoteRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT quote_id, designation_code, address, payer_address, chain_id, currency, amount_atomic, decimals, contract_address, method, calldata, value_hex, sponsorship_mode, sponsor_org_root_id, created_at, expires_at, confirmed_at, confirmed_tx_hash + FROM quotes + WHERE quote_id = ? + `, quoteID) + var rec quoteRecord + var created, expires, confirmed sql.NullString + var confirmedTx, payerAddress, sponsorOrgRootID sql.NullString + err := row.Scan( + &rec.QuoteID, + &rec.DesignationCode, + &rec.Address, + &payerAddress, + &rec.ChainID, + &rec.Currency, + &rec.AmountAtomic, + &rec.Decimals, + &rec.ContractAddress, + &rec.Method, + &rec.Calldata, + &rec.ValueHex, + &rec.SponsorshipMode, + &sponsorOrgRootID, + &created, + &expires, + &confirmed, + &confirmedTx, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return quoteRecord{}, errNotFound + } + return quoteRecord{}, err + } + rec.PayerAddress = payerAddress.String + rec.SponsorOrgRootID = sponsorOrgRootID.String + rec.CreatedAt = parseRFC3339Nullable(created) + rec.ExpiresAt = parseRFC3339Nullable(expires) + rec.ConfirmedAt = parseRFC3339Ptr(confirmed) + rec.ConfirmedTxHash = confirmedTx.String + return rec, nil +} + +func (s *store) putGovernancePrincipal(ctx context.Context, rec governancePrincipalRecord) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO governance_principals ( + wallet, org_root_id, principal_id, principal_role, entitlement_id, entitlement_status, access_class, availability_state, lease_expires_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(wallet) DO UPDATE SET + org_root_id=excluded.org_root_id, + principal_id=excluded.principal_id, + principal_role=excluded.principal_role, + entitlement_id=excluded.entitlement_id, + entitlement_status=excluded.entitlement_status, + access_class=excluded.access_class, + availability_state=excluded.availability_state, + lease_expires_at=excluded.lease_expires_at, + updated_at=excluded.updated_at + `, rec.Wallet, rec.OrgRootID, rec.PrincipalID, rec.PrincipalRole, rec.EntitlementID, rec.EntitlementStatus, rec.AccessClass, rec.AvailabilityState, formatNullableTime(rec.LeaseExpiresAt), rec.UpdatedAt.UTC().Format(time.RFC3339Nano)) + return err +} + +func (s *store) getGovernancePrincipal(ctx context.Context, wallet string) (governancePrincipalRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT wallet, org_root_id, principal_id, principal_role, entitlement_id, entitlement_status, access_class, availability_state, lease_expires_at, updated_at + FROM governance_principals + WHERE wallet = ? + `, strings.ToLower(strings.TrimSpace(wallet))) + var rec governancePrincipalRecord + var leaseExpiresAt, updatedAt sql.NullString + err := row.Scan(&rec.Wallet, &rec.OrgRootID, &rec.PrincipalID, &rec.PrincipalRole, &rec.EntitlementID, &rec.EntitlementStatus, &rec.AccessClass, &rec.AvailabilityState, &leaseExpiresAt, &updatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return governancePrincipalRecord{}, errNotFound + } + return governancePrincipalRecord{}, err + } + rec.LeaseExpiresAt = parseRFC3339Ptr(leaseExpiresAt) + rec.UpdatedAt = parseRFC3339Nullable(updatedAt) + return rec, nil +} + +func (s *store) putGovernanceInstallToken(ctx context.Context, rec governanceInstallTokenRecord) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO governance_install_tokens ( + install_token, wallet, org_root_id, principal_id, principal_role, device_id, entitlement_id, package_hash, runtime_version, policy_hash, issued_at, expires_at, consumed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(install_token) DO UPDATE SET + wallet=excluded.wallet, + org_root_id=excluded.org_root_id, + principal_id=excluded.principal_id, + principal_role=excluded.principal_role, + device_id=excluded.device_id, + entitlement_id=excluded.entitlement_id, + package_hash=excluded.package_hash, + runtime_version=excluded.runtime_version, + policy_hash=excluded.policy_hash, + issued_at=excluded.issued_at, + expires_at=excluded.expires_at, + consumed_at=excluded.consumed_at + `, rec.InstallToken, rec.Wallet, rec.OrgRootID, rec.PrincipalID, rec.PrincipalRole, rec.DeviceID, rec.EntitlementID, rec.PackageHash, rec.RuntimeVersion, rec.PolicyHash, rec.IssuedAt.UTC().Format(time.RFC3339Nano), rec.ExpiresAt.UTC().Format(time.RFC3339Nano), formatNullableTime(rec.ConsumedAt)) + return err +} + +func (s *store) getGovernanceInstallToken(ctx context.Context, token string) (governanceInstallTokenRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT install_token, wallet, org_root_id, principal_id, principal_role, device_id, entitlement_id, package_hash, runtime_version, policy_hash, issued_at, expires_at, consumed_at + FROM governance_install_tokens + WHERE install_token = ? + `, strings.TrimSpace(token)) + var rec governanceInstallTokenRecord + var issuedAt, expiresAt, consumedAt sql.NullString + err := row.Scan(&rec.InstallToken, &rec.Wallet, &rec.OrgRootID, &rec.PrincipalID, &rec.PrincipalRole, &rec.DeviceID, &rec.EntitlementID, &rec.PackageHash, &rec.RuntimeVersion, &rec.PolicyHash, &issuedAt, &expiresAt, &consumedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return governanceInstallTokenRecord{}, errNotFound + } + return governanceInstallTokenRecord{}, err + } + rec.IssuedAt = parseRFC3339Nullable(issuedAt) + rec.ExpiresAt = parseRFC3339Nullable(expiresAt) + rec.ConsumedAt = parseRFC3339Ptr(consumedAt) + return rec, nil +} + +func (s *store) putGovernanceInstall(ctx context.Context, rec governanceInstallRecord) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO governance_installs ( + install_token, wallet, device_id, entitlement_id, runtime_version, package_hash, policy_hash, launcher_receipt_ref, installed_at, activated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(install_token) DO UPDATE SET + wallet=excluded.wallet, + device_id=excluded.device_id, + entitlement_id=excluded.entitlement_id, + runtime_version=excluded.runtime_version, + package_hash=excluded.package_hash, + policy_hash=excluded.policy_hash, + launcher_receipt_ref=excluded.launcher_receipt_ref, + installed_at=excluded.installed_at, + activated_at=excluded.activated_at + `, rec.InstallToken, rec.Wallet, rec.DeviceID, rec.EntitlementID, rec.RuntimeVersion, rec.PackageHash, rec.PolicyHash, nullableString(rec.LauncherReceiptRef), rec.InstalledAt.UTC().Format(time.RFC3339Nano), rec.ActivatedAt.UTC().Format(time.RFC3339Nano)) + return err +} + +func (s *store) getGovernanceInstallByToken(ctx context.Context, token string) (governanceInstallRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT install_token, wallet, device_id, entitlement_id, runtime_version, package_hash, policy_hash, launcher_receipt_ref, installed_at, activated_at + FROM governance_installs + WHERE install_token = ? + `, strings.TrimSpace(token)) + return scanGovernanceInstall(row) +} + +func (s *store) getGovernanceInstallByDevice(ctx context.Context, wallet, deviceID string) (governanceInstallRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT install_token, wallet, device_id, entitlement_id, runtime_version, package_hash, policy_hash, launcher_receipt_ref, installed_at, activated_at + FROM governance_installs + WHERE wallet = ? AND device_id = ? + ORDER BY activated_at DESC + LIMIT 1 + `, strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(deviceID)) + return scanGovernanceInstall(row) +} + +func scanGovernanceInstall(row interface{ Scan(dest ...any) error }) (governanceInstallRecord, error) { + var rec governanceInstallRecord + var launcherReceiptRef, installedAt, activatedAt sql.NullString + err := row.Scan(&rec.InstallToken, &rec.Wallet, &rec.DeviceID, &rec.EntitlementID, &rec.RuntimeVersion, &rec.PackageHash, &rec.PolicyHash, &launcherReceiptRef, &installedAt, &activatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return governanceInstallRecord{}, errNotFound + } + return governanceInstallRecord{}, err + } + rec.LauncherReceiptRef = launcherReceiptRef.String + rec.InstalledAt = parseRFC3339Nullable(installedAt) + rec.ActivatedAt = parseRFC3339Nullable(activatedAt) + return rec, nil +} + +func (s *store) putMemberChannelBinding(ctx context.Context, rec memberChannelBindingRecord) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO member_channel_bindings ( + channel_binding_id, wallet, chain_id, device_id, platform, org_root_id, principal_id, principal_role, app_version, push_provider, push_token, status, created_at, updated_at, removed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(wallet, device_id) DO UPDATE SET + channel_binding_id=excluded.channel_binding_id, + chain_id=excluded.chain_id, + platform=excluded.platform, + org_root_id=excluded.org_root_id, + principal_id=excluded.principal_id, + principal_role=excluded.principal_role, + app_version=excluded.app_version, + push_provider=excluded.push_provider, + push_token=excluded.push_token, + status=excluded.status, + updated_at=excluded.updated_at, + removed_at=excluded.removed_at + `, rec.ChannelBindingID, rec.Wallet, rec.ChainID, rec.DeviceID, rec.Platform, rec.OrgRootID, rec.PrincipalID, rec.PrincipalRole, rec.AppVersion, rec.PushProvider, nullableString(rec.PushToken), rec.Status, rec.CreatedAt.UTC().Format(time.RFC3339Nano), rec.UpdatedAt.UTC().Format(time.RFC3339Nano), formatNullableTime(rec.RemovedAt)) + return err +} + +func (s *store) getMemberChannelBinding(ctx context.Context, wallet, deviceID string) (memberChannelBindingRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT channel_binding_id, wallet, chain_id, device_id, platform, org_root_id, principal_id, principal_role, app_version, push_provider, push_token, status, created_at, updated_at, removed_at + FROM member_channel_bindings + WHERE wallet = ? AND device_id = ? AND status = 'active' + `, strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(deviceID)) + return scanMemberChannelBinding(row) +} + +func (s *store) getMemberChannelBindingByPrincipal(ctx context.Context, wallet, orgRootID, principalID string) (memberChannelBindingRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT channel_binding_id, wallet, chain_id, device_id, platform, org_root_id, principal_id, principal_role, app_version, push_provider, push_token, status, created_at, updated_at, removed_at + FROM member_channel_bindings + WHERE wallet = ? AND org_root_id = ? AND principal_id = ? AND status = 'active' + ORDER BY updated_at DESC + LIMIT 1 + `, strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(orgRootID), strings.TrimSpace(principalID)) + return scanMemberChannelBinding(row) +} + +func scanMemberChannelBinding(row interface{ Scan(dest ...any) error }) (memberChannelBindingRecord, error) { + var rec memberChannelBindingRecord + var pushToken, createdAt, updatedAt, removedAt sql.NullString + err := row.Scan( + &rec.ChannelBindingID, + &rec.Wallet, + &rec.ChainID, + &rec.DeviceID, + &rec.Platform, + &rec.OrgRootID, + &rec.PrincipalID, + &rec.PrincipalRole, + &rec.AppVersion, + &rec.PushProvider, + &pushToken, + &rec.Status, + &createdAt, + &updatedAt, + &removedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return memberChannelBindingRecord{}, errNotFound + } + return memberChannelBindingRecord{}, err + } + rec.PushToken = pushToken.String + rec.CreatedAt = parseRFC3339Nullable(createdAt) + rec.UpdatedAt = parseRFC3339Nullable(updatedAt) + rec.RemovedAt = parseRFC3339Ptr(removedAt) + return rec, nil +} + +func (s *store) removeMemberChannelBinding(ctx context.Context, wallet, deviceID string, removedAt time.Time) error { + result, err := s.db.ExecContext(ctx, ` + UPDATE member_channel_bindings + SET status = 'removed', removed_at = ?, updated_at = ? + WHERE wallet = ? AND device_id = ? AND status = 'active' + `, removedAt.UTC().Format(time.RFC3339Nano), removedAt.UTC().Format(time.RFC3339Nano), strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(deviceID)) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return errNotFound + } + return nil +} + +func (s *store) putMemberChannelEvent(ctx context.Context, rec memberChannelEventRecord) error { + if strings.TrimSpace(rec.EventID) == "" { + id, err := randomHex(8) + if err != nil { + return err + } + rec.EventID = "evt_" + id + } + _, 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 + `, rec.EventID, strings.ToLower(strings.TrimSpace(rec.Wallet)), strings.TrimSpace(rec.OrgRootID), strings.TrimSpace(rec.PrincipalID), strings.TrimSpace(rec.Class), rec.CreatedAt.UTC().Format(time.RFC3339Nano), rec.Title, rec.Body, rec.DedupeKey, boolToInt(rec.RequiresAck), rec.PolicyHash, rec.PayloadJSON, strings.TrimSpace(rec.VisibilityScope)) + return err +} + +func (s *store) getMemberChannelEventSeqByID(ctx context.Context, eventID string) (int64, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT seq + FROM member_channel_events + WHERE event_id = ? + `, strings.TrimSpace(eventID)) + var seq int64 + if err := row.Scan(&seq); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, errNotFound + } + return 0, err + } + return seq, nil +} + +func (s *store) listMemberChannelEvents(ctx context.Context, wallet, orgRootID string, includeOwnerAdmin bool, cursor string, limit int) ([]memberChannelEventRecord, string, error) { + cursorSeq := int64(0) + if strings.TrimSpace(cursor) != "" { + seq, err := s.getMemberChannelEventSeqByID(ctx, cursor) + if err == nil { + cursorSeq = seq + } + } + visibilityClause := "visibility_scope = 'member'" + if includeOwnerAdmin { + visibilityClause = "(visibility_scope = 'member' OR visibility_scope = 'owner_admin')" + } + rows, err := s.db.QueryContext(ctx, ` + SELECT seq, event_id, wallet, org_root_id, principal_id, class, created_at, title, body, dedupe_key, requires_ack, policy_hash, payload_json, visibility_scope + FROM member_channel_events + WHERE wallet = ? AND org_root_id = ? AND seq > ? AND `+visibilityClause+` + ORDER BY seq ASC + LIMIT ? + `, strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(orgRootID), cursorSeq, limit) + if err != nil { + return nil, "", err + } + defer rows.Close() + + events := make([]memberChannelEventRecord, 0, limit) + nextCursor := "" + for rows.Next() { + var rec memberChannelEventRecord + var createdAt sql.NullString + var requiresAck int + if err := rows.Scan( + &rec.Seq, + &rec.EventID, + &rec.Wallet, + &rec.OrgRootID, + &rec.PrincipalID, + &rec.Class, + &createdAt, + &rec.Title, + &rec.Body, + &rec.DedupeKey, + &requiresAck, + &rec.PolicyHash, + &rec.PayloadJSON, + &rec.VisibilityScope, + ); err != nil { + return nil, "", err + } + rec.CreatedAt = parseRFC3339Nullable(createdAt) + rec.RequiresAck = requiresAck == 1 + events = append(events, rec) + nextCursor = rec.EventID + } + if err := rows.Err(); err != nil { + return nil, "", err + } + return events, nextCursor, nil +} + +func (s *store) getMemberChannelEventByID(ctx context.Context, eventID string) (memberChannelEventRecord, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT seq, event_id, wallet, org_root_id, principal_id, class, created_at, title, body, dedupe_key, requires_ack, policy_hash, payload_json, visibility_scope + FROM member_channel_events + WHERE event_id = ? + `, strings.TrimSpace(eventID)) + var rec memberChannelEventRecord + var createdAt sql.NullString + var requiresAck int + err := row.Scan(&rec.Seq, &rec.EventID, &rec.Wallet, &rec.OrgRootID, &rec.PrincipalID, &rec.Class, &createdAt, &rec.Title, &rec.Body, &rec.DedupeKey, &requiresAck, &rec.PolicyHash, &rec.PayloadJSON, &rec.VisibilityScope) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return memberChannelEventRecord{}, errNotFound + } + return memberChannelEventRecord{}, err + } + rec.CreatedAt = parseRFC3339Nullable(createdAt) + rec.RequiresAck = requiresAck == 1 + return rec, nil +} + +func (s *store) putMemberChannelEventAck(ctx context.Context, eventID, wallet, deviceID string, acknowledgedAt time.Time) (time.Time, error) { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO member_channel_event_acks (event_id, wallet, device_id, acknowledged_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(event_id, wallet, device_id) DO NOTHING + `, strings.TrimSpace(eventID), strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(deviceID), acknowledgedAt.UTC().Format(time.RFC3339Nano)) + if err != nil { + return time.Time{}, err + } + row := s.db.QueryRowContext(ctx, ` + SELECT acknowledged_at + FROM member_channel_event_acks + WHERE event_id = ? AND wallet = ? AND device_id = ? + `, strings.TrimSpace(eventID), strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(deviceID)) + var ackRaw sql.NullString + if err := row.Scan(&ackRaw); err != nil { + return time.Time{}, err + } + return parseRFC3339Nullable(ackRaw), nil +} + +func (s *store) putMemberChannelSupportTicket(ctx context.Context, rec memberChannelSupportTicketRecord) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO member_support_tickets ( + ticket_id, wallet, org_root_id, principal_id, category, summary, context_json, status, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, rec.TicketID, strings.ToLower(strings.TrimSpace(rec.Wallet)), strings.TrimSpace(rec.OrgRootID), strings.TrimSpace(rec.PrincipalID), strings.TrimSpace(rec.Category), rec.Summary, rec.ContextJSON, strings.TrimSpace(rec.Status), rec.CreatedAt.UTC().Format(time.RFC3339Nano)) + return err +} + +func parseRFC3339Nullable(raw sql.NullString) time.Time { + if !raw.Valid || strings.TrimSpace(raw.String) == "" { + return time.Time{} + } + ts, err := time.Parse(time.RFC3339Nano, raw.String) + if err != nil { + return time.Time{} + } + return ts.UTC() +} + +func parseRFC3339Ptr(raw sql.NullString) *time.Time { + ts := parseRFC3339Nullable(raw) + if ts.IsZero() { + return nil + } + return &ts +} + +func formatNullableTime(ts *time.Time) any { + if ts == nil || ts.IsZero() { + return nil + } + return ts.UTC().Format(time.RFC3339Nano) +} + +func nullableString(v string) any { + if strings.TrimSpace(v) == "" { + return nil + } + return v +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/docs/api/examples/secret-system.examples.md b/docs/api/examples/secret-system.examples.md index 5c89a56..0cadb58 100644 --- a/docs/api/examples/secret-system.examples.md +++ b/docs/api/examples/secret-system.examples.md @@ -33,8 +33,9 @@ Error (`429` rate limited): ```json { - "error": "rate_limited", - "message": "Too many intent requests. Retry later." + "error": "Too many intent requests. Retry later.", + "code": "rate_limited", + "correlation_id": "req_3f5b42e0f1a9e8c1" } ``` @@ -66,8 +67,9 @@ Error (`400` intent expired): ```json { - "error": "intent_expired", - "message": "Intent has expired. Request a new intent." + "error": "Intent has expired. Request a new intent.", + "code": "intent_expired", + "correlation_id": "req_8e7e75da2fcb9bd0" } ``` @@ -79,7 +81,10 @@ Request: { "designation_code": "0217073045482", "address": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5", - "chain_id": 8453 + "chain_id": 8453, + "payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11", + "payer_proof": "0xowner-signed-proof", + "sponsor_org_root_id": "org_company_a" } ``` @@ -98,7 +103,12 @@ Success (`200`): "method": "mintMembership", "calldata": "0xdeadbeef", "value": "0x0", + "owner_wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5", + "payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11", + "sponsorship_mode": "sponsored_company", + "sponsor_org_root_id": "org_company_a", "tx": { + "from": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11", "to": "0x1111111111111111111111111111111111111111", "data": "0xdeadbeef", "value": "0x0" @@ -110,8 +120,18 @@ Error (`403` not verified): ```json { - "error": "signature_not_verified", - "message": "Signature verification is required before quote issuance." + "error": "Signature verification is required before quote issuance.", + "code": "signature_not_verified", + "correlation_id": "req_b2fd89f71a4d17d4" +} +``` + +Error (`403` distinct payer without proof): + +```json +{ + "error": "distinct payer requires ownership proof", + "code": "request_failed" } ``` @@ -145,8 +165,9 @@ Error (`400` tx mismatch): ```json { - "error": "tx_mismatch", - "message": "Transaction amount or destination does not match quote policy." + "error": "Transaction amount or destination does not match quote policy.", + "code": "tx_mismatch", + "correlation_id": "req_6d1227be9f9b75cc" } ``` @@ -170,7 +191,8 @@ Error (`400` missing selectors): ```json { - "error": "missing_selector", - "message": "Provide wallet or designation_code." + "error": "Provide wallet or designation_code.", + "code": "missing_selector", + "correlation_id": "req_f23618fdd4479b89" } ``` diff --git a/docs/api/secret-system.openapi.yaml b/docs/api/secret-system.openapi.yaml index 00e2ab5..0d751ca 100644 --- a/docs/api/secret-system.openapi.yaml +++ b/docs/api/secret-system.openapi.yaml @@ -170,6 +170,14 @@ components: pattern: '^0x[a-fA-F0-9]{40}$' chain_id: type: integer + payer_wallet: + type: string + pattern: '^0x[a-fA-F0-9]{40}$' + payer_proof: + type: string + description: Owner-signed personal-signature proof for distinct payer wallets. + sponsor_org_root_id: + type: string MembershipQuoteResponse: type: object required: [quote_id, chain_id, currency, amount_atomic, deadline, contract_address] @@ -198,6 +206,15 @@ components: type: string value: type: string + owner_wallet: + type: string + payer_wallet: + type: string + sponsorship_mode: + type: string + enum: [self, sponsored, sponsored_company] + sponsor_org_root_id: + type: string tx: type: object additionalProperties: true diff --git a/docs/deployment/README.md b/docs/deployment/README.md index b49f427..b20022a 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -10,6 +10,9 @@ These templates are deployment-time placeholders for membership-gated commerce. 2. `contract-addresses.template.json` - Canonical contract and treasury addresses. +3. `secretapi-deploy.md` +- Backend deployment runbook for membership + governance install APIs. + ## Usage 1. Copy templates to environment-specific files. diff --git a/docs/deployment/secretapi-deploy.md b/docs/deployment/secretapi-deploy.md new file mode 100644 index 0000000..3447502 --- /dev/null +++ b/docs/deployment/secretapi-deploy.md @@ -0,0 +1,77 @@ +# Secret API Deployment (Staging/Main) + +This runbook deploys `web/backend/secretapi` for wallet-first membership and governance install authorization. + +## Build Targets + +1. Native binary: + +```bash +cd /Users/vsg/Documents/VSG\ Codex/web/backend/secretapi +go build -o secretapi . +``` + +2. Container image: + +```bash +cd /Users/vsg/Documents/VSG\ Codex/web/backend/secretapi +docker build -t edut/secretapi:latest . +``` + +## Required Environment + +Use `web/backend/secretapi/.env.example` as baseline. + +Critical values before launch: + +1. `SECRET_API_CHAIN_ID` (`84532` for Base Sepolia, `8453` for Base mainnet) +2. `SECRET_API_CHAIN_RPC_URL` +3. `SECRET_API_VERIFYING_CONTRACT` +4. `SECRET_API_MEMBERSHIP_CONTRACT` +5. Governance package metadata: + - `SECRET_API_GOV_RUNTIME_VERSION` + - `SECRET_API_GOV_PACKAGE_URL` + - `SECRET_API_GOV_PACKAGE_HASH` + - `SECRET_API_GOV_PACKAGE_SIGNATURE` + - `SECRET_API_GOV_SIGNER_KEY_ID` + - `SECRET_API_GOV_POLICY_HASH` +6. Member channel polling: + - `SECRET_API_MEMBER_POLL_INTERVAL_SECONDS` + +## Systemd Deployment (Hetzner/VPS) + +1. Copy binary to `/opt/edut/secretapi/secretapi`. +2. Copy environment file to `/etc/edut/secretapi.env`. +3. Copy unit file `web/backend/secretapi/deploy/secretapi.service` to `/etc/systemd/system/secretapi.service`. +4. Start service: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now secretapi +sudo systemctl status secretapi +``` + +## Health Check + +```bash +curl -s http://127.0.0.1:8080/healthz +``` + +Expected: + +```json +{"status":"ok"} +``` + +## Post-Deploy Verification + +1. `POST /secret/wallet/intent` returns `intent_id` and `designation_code`. +2. `POST /secret/wallet/verify` accepts valid EIP-712 signature. +3. `POST /secret/membership/quote` returns tx payload. +4. `POST /secret/membership/confirm` marks membership active. +5. `POST /governance/install/token` enforces owner role and active membership. +6. `POST /governance/install/confirm` enforces package/runtime/policy match. +7. `GET /governance/install/status` resolves deterministic activation state. +8. `POST /member/channel/device/register` returns active channel binding. +9. `GET /member/channel/events` returns deterministic inbox page. +10. `POST /member/channel/events/{event_id}/ack` is idempotent per event+device. diff --git a/docs/governance-install-spec.md b/docs/governance-install-spec.md index 8ae1abf..e06c94c 100644 --- a/docs/governance-install-spec.md +++ b/docs/governance-install-spec.md @@ -29,6 +29,25 @@ This spec defines deterministic installation of the governance runtime after mem 9. Governance runtime re-verifies entitlement receipt and policy hash. 10. Runtime activation state transitions to `ACTIVE`. +## Provisioning Profiles + +The install flow supports multiple interaction profiles on the same governed path: + +1. `quick` (recommended): sequential connector/auth setup with minimal operator overhead. +2. `manual`: explicit step-by-step setup for each integration. +3. `advanced_edut_bootstrap`: owner-only fast provisioning for managed devices. + +Profile rules: + +1. `advanced_edut_bootstrap` is only available to `ORG_ROOT_OWNER`. +2. `workspace_member` principals must never see or invoke advanced install controls. +3. Advanced profile may streamline permission/setup orchestration, but cannot skip: + - package hash/signature verification + - entitlement validation + - boundary checks + - provider/connector OAuth consent boundaries +4. All profile selections and resulting scope grants must be evidence-logged. + ## Activation State Machine `NOT_INSTALLED` -> `DOWNLOADED` -> `VERIFIED` -> `BOOTSTRAPPED` -> `ACTIVE` @@ -49,6 +68,8 @@ Failure states: 5. Reinstall with same package hash must be idempotent. 6. Boundary mismatch or `PARKED` availability state blocks install token issuance. 7. Non-owner principal role blocks install/update control paths. +8. Any attempt to invoke `advanced_edut_bootstrap` as non-owner is rejected and audit-logged. +9. Advanced bootstrap cannot be used to widen workspace boundary or entitlement scope. ## Ownership vs Payment Wallet diff --git a/docs/handoff/governance-backend-checklist.md b/docs/handoff/governance-backend-checklist.md index fd7d6a9..8f03753 100644 --- a/docs/handoff/governance-backend-checklist.md +++ b/docs/handoff/governance-backend-checklist.md @@ -14,6 +14,7 @@ This checklist maps launcher-governance install behavior to backend requirements 1. `docs/api/governance-installer.openapi.yaml` 2. `docs/api/examples/governance-installer.examples.md` +3. Runtime implementation target: `web/backend/secretapi` ## Required Gate Behavior @@ -53,3 +54,4 @@ This checklist maps launcher-governance install behavior to backend requirements 2. Runtime cannot activate if package signature/hash checks fail. 3. `governance_active` status is deterministic and auditable. 4. API implementation matches OpenAPI contract. +5. Non-owner (`workspace_member`) install-token requests are rejected deterministically. diff --git a/docs/handoff/member-channel-backend-checklist.md b/docs/handoff/member-channel-backend-checklist.md index 401885e..7d4621d 100644 --- a/docs/handoff/member-channel-backend-checklist.md +++ b/docs/handoff/member-channel-backend-checklist.md @@ -2,6 +2,11 @@ This checklist defines backend requirements for app-native member communication. +Implementation status: + +1. Local reference implementation exists in `/Users/vsg/Documents/VSG Codex/web/backend/secretapi` (sqlite-backed) for register/unregister/events/ack/support. +2. Production deployment + wallet-session auth hardening still required before launch. + ## Required Endpoints 1. `POST /member/channel/device/register` diff --git a/docs/handoff/membership-backend-checklist.md b/docs/handoff/membership-backend-checklist.md index 327e880..1b433b1 100644 --- a/docs/handoff/membership-backend-checklist.md +++ b/docs/handoff/membership-backend-checklist.md @@ -2,6 +2,10 @@ This checklist maps current web behavior to required backend implementation. +Current implementation target in this repo: + +- `web/backend/secretapi` + ## Required Endpoints 1. `POST /secret/wallet/intent` @@ -59,6 +63,10 @@ Must return: 6. tx execution fields: - either `tx` object for wallet send - or `contract_address` + `calldata` + `value` +7. ownership/payer context fields when applicable: + - `owner_wallet` + - `payer_wallet` + - `sponsorship_mode` ## Membership Confirm @@ -84,6 +92,13 @@ Must return: 4. Origin allowlist checks. 5. Tx amount/currency/recipient exact-match checks. 6. Idempotent confirm path for repeated tx_hash submissions. +7. Distinct payer wallet requires deterministic ownership proof. +8. Ownership proof message contract: + - `EDUT-PAYER-AUTH:{designation_code}:{owner_wallet}:{payer_wallet}:{chain_id}` +9. Company-first sponsor path allowed when: + - `sponsor_org_root_id` is provided, + - payer wallet is an `org_root_owner` principal for that org root, + - payer entitlement status is active. ## Data Persistence Requirements @@ -113,3 +128,4 @@ Persist at minimum: 2. Membership inactive wallets cannot complete flow. 3. Confirm endpoint is idempotent and deterministic. 4. API matches `docs/api/secret-system.openapi.yaml`. +5. Distinct payer requests fail closed without ownership proof. diff --git a/docs/roadmap-status.md b/docs/roadmap-status.md index 4f1540e..2c18d40 100644 --- a/docs/roadmap-status.md +++ b/docs/roadmap-status.md @@ -12,7 +12,7 @@ Status key: 2. Freeze token taxonomy: `DONE` 3. Finalize membership contract interface targets: `DONE` 4. Lock signature + intent protocol: `DONE` -5. Add membership mint transaction stage in web flow: `DONE` (frontend path implemented; backend endpoints pending) +5. Add membership mint transaction stage in web flow: `DONE` (frontend + local backend implementation complete; deploy pending credentials) 6. Implement membership gate in marketplace checkout: `IN_PROGRESS` (store scaffold + gate logic implemented; live API pending) 7. Ship offer registry schema: `DONE` 8. Ship entitlement purchase schema/pipeline contracts: `IN_PROGRESS` @@ -52,6 +52,8 @@ Implemented now: 23. Local split repos (`launcher`, `governance`, `contracts`) are initialized with seed commits and a publish runbook. 24. Boundary and availability model documented with deterministic state machine and conformance vectors. 25. Owner-gated admin/support model documented in API contracts, terms, and conformance vectors. +26. Local backend implementation (`web/backend/secretapi`) now serves membership endpoints, governance install/lease endpoints, sponsor-aware payer flow, and deterministic integration tests. +27. Local backend member app channel endpoints now serve deterministic register/unregister, poll, idempotent ack, and owner-only support ticket flows with sqlite-backed event/audit state. Remaining in this repo: @@ -61,10 +63,10 @@ Remaining in this repo: Cross-repo dependencies (kernel/backend/contracts): -1. Implement `/secret/membership/quote` and `/secret/membership/confirm`. -2. Implement membership contract and membership status reads. +1. Implement `/secret/membership/quote` and `/secret/membership/confirm`: `IN_PROGRESS` (local implementation in `web/backend/secretapi`; deployment pending credentials). +2. Implement membership contract and membership status reads: `IN_PROGRESS` (contract + local status path implemented; chain deployment pending). 3. Implement checkout APIs and entitlement mint pipeline. 4. Implement runtime entitlement gate and evidence receipts. -5. Implement member app channel APIs and deterministic event stream storage. -6. Implement governance install token/confirm/status APIs and signed package delivery. -7. Implement org-root boundary claims and access class state transitions in runtime/API responses. +5. Implement member app channel APIs and deterministic event stream storage: `IN_PROGRESS` (local implementation in `web/backend/secretapi`; deployment pending credentials). +6. Implement governance install token/confirm/status APIs and signed package delivery: `IN_PROGRESS` (local implementation in `web/backend/secretapi`; package signing/deploy wiring pending). +7. Implement org-root boundary claims and access class state transitions in runtime/API responses: `IN_PROGRESS` (principal/access-class scaffolding implemented locally; full runtime integration pending).