Add secretapi member channel endpoints and deployment hardening

This commit is contained in:
Joshua 2026-02-17 20:48:19 -08:00
parent bfc374b9ce
commit 27b5900205
23 changed files with 3869 additions and 17 deletions

View File

@ -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

View File

@ -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"]

View File

@ -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`)

1359
backend/secretapi/app.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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
}

132
backend/secretapi/crypto.go Normal file
View File

@ -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
}

View File

@ -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

39
backend/secretapi/go.mod Normal file
View File

@ -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
)

67
backend/secretapi/go.sum Normal file
View File

@ -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=

32
backend/secretapi/main.go Normal file
View File

@ -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)
}
}

383
backend/secretapi/models.go Normal file
View File

@ -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
}

788
backend/secretapi/store.go Normal file
View File

@ -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, &notNull, &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
}

View File

@ -33,8 +33,9 @@ Error (`429` rate limited):
```json ```json
{ {
"error": "rate_limited", "error": "Too many intent requests. Retry later.",
"message": "Too many intent requests. Retry later." "code": "rate_limited",
"correlation_id": "req_3f5b42e0f1a9e8c1"
} }
``` ```
@ -66,8 +67,9 @@ Error (`400` intent expired):
```json ```json
{ {
"error": "intent_expired", "error": "Intent has expired. Request a new intent.",
"message": "Intent has expired. Request a new intent." "code": "intent_expired",
"correlation_id": "req_8e7e75da2fcb9bd0"
} }
``` ```
@ -79,7 +81,10 @@ Request:
{ {
"designation_code": "0217073045482", "designation_code": "0217073045482",
"address": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5", "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", "method": "mintMembership",
"calldata": "0xdeadbeef", "calldata": "0xdeadbeef",
"value": "0x0", "value": "0x0",
"owner_wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
"payer_wallet": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11",
"sponsorship_mode": "sponsored_company",
"sponsor_org_root_id": "org_company_a",
"tx": { "tx": {
"from": "0x2299547f6fA9A8f9b6d9aEA9F9D8A4B53C8A0e11",
"to": "0x1111111111111111111111111111111111111111", "to": "0x1111111111111111111111111111111111111111",
"data": "0xdeadbeef", "data": "0xdeadbeef",
"value": "0x0" "value": "0x0"
@ -110,8 +120,18 @@ Error (`403` not verified):
```json ```json
{ {
"error": "signature_not_verified", "error": "Signature verification is required before quote issuance.",
"message": "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 ```json
{ {
"error": "tx_mismatch", "error": "Transaction amount or destination does not match quote policy.",
"message": "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 ```json
{ {
"error": "missing_selector", "error": "Provide wallet or designation_code.",
"message": "Provide wallet or designation_code." "code": "missing_selector",
"correlation_id": "req_f23618fdd4479b89"
} }
``` ```

View File

@ -170,6 +170,14 @@ components:
pattern: '^0x[a-fA-F0-9]{40}$' pattern: '^0x[a-fA-F0-9]{40}$'
chain_id: chain_id:
type: integer 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: MembershipQuoteResponse:
type: object type: object
required: [quote_id, chain_id, currency, amount_atomic, deadline, contract_address] required: [quote_id, chain_id, currency, amount_atomic, deadline, contract_address]
@ -198,6 +206,15 @@ components:
type: string type: string
value: value:
type: string 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: tx:
type: object type: object
additionalProperties: true additionalProperties: true

View File

@ -10,6 +10,9 @@ These templates are deployment-time placeholders for membership-gated commerce.
2. `contract-addresses.template.json` 2. `contract-addresses.template.json`
- Canonical contract and treasury addresses. - Canonical contract and treasury addresses.
3. `secretapi-deploy.md`
- Backend deployment runbook for membership + governance install APIs.
## Usage ## Usage
1. Copy templates to environment-specific files. 1. Copy templates to environment-specific files.

View File

@ -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.

View File

@ -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. 9. Governance runtime re-verifies entitlement receipt and policy hash.
10. Runtime activation state transitions to `ACTIVE`. 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 ## Activation State Machine
`NOT_INSTALLED` -> `DOWNLOADED` -> `VERIFIED` -> `BOOTSTRAPPED` -> `ACTIVE` `NOT_INSTALLED` -> `DOWNLOADED` -> `VERIFIED` -> `BOOTSTRAPPED` -> `ACTIVE`
@ -49,6 +68,8 @@ Failure states:
5. Reinstall with same package hash must be idempotent. 5. Reinstall with same package hash must be idempotent.
6. Boundary mismatch or `PARKED` availability state blocks install token issuance. 6. Boundary mismatch or `PARKED` availability state blocks install token issuance.
7. Non-owner principal role blocks install/update control paths. 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 ## Ownership vs Payment Wallet

View File

@ -14,6 +14,7 @@ This checklist maps launcher-governance install behavior to backend requirements
1. `docs/api/governance-installer.openapi.yaml` 1. `docs/api/governance-installer.openapi.yaml`
2. `docs/api/examples/governance-installer.examples.md` 2. `docs/api/examples/governance-installer.examples.md`
3. Runtime implementation target: `web/backend/secretapi`
## Required Gate Behavior ## 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. 2. Runtime cannot activate if package signature/hash checks fail.
3. `governance_active` status is deterministic and auditable. 3. `governance_active` status is deterministic and auditable.
4. API implementation matches OpenAPI contract. 4. API implementation matches OpenAPI contract.
5. Non-owner (`workspace_member`) install-token requests are rejected deterministically.

View File

@ -2,6 +2,11 @@
This checklist defines backend requirements for app-native member communication. 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 ## Required Endpoints
1. `POST /member/channel/device/register` 1. `POST /member/channel/device/register`

View File

@ -2,6 +2,10 @@
This checklist maps current web behavior to required backend implementation. This checklist maps current web behavior to required backend implementation.
Current implementation target in this repo:
- `web/backend/secretapi`
## Required Endpoints ## Required Endpoints
1. `POST /secret/wallet/intent` 1. `POST /secret/wallet/intent`
@ -59,6 +63,10 @@ Must return:
6. tx execution fields: 6. tx execution fields:
- either `tx` object for wallet send - either `tx` object for wallet send
- or `contract_address` + `calldata` + `value` - or `contract_address` + `calldata` + `value`
7. ownership/payer context fields when applicable:
- `owner_wallet`
- `payer_wallet`
- `sponsorship_mode`
## Membership Confirm ## Membership Confirm
@ -84,6 +92,13 @@ Must return:
4. Origin allowlist checks. 4. Origin allowlist checks.
5. Tx amount/currency/recipient exact-match checks. 5. Tx amount/currency/recipient exact-match checks.
6. Idempotent confirm path for repeated tx_hash submissions. 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 ## Data Persistence Requirements
@ -113,3 +128,4 @@ Persist at minimum:
2. Membership inactive wallets cannot complete flow. 2. Membership inactive wallets cannot complete flow.
3. Confirm endpoint is idempotent and deterministic. 3. Confirm endpoint is idempotent and deterministic.
4. API matches `docs/api/secret-system.openapi.yaml`. 4. API matches `docs/api/secret-system.openapi.yaml`.
5. Distinct payer requests fail closed without ownership proof.

View File

@ -12,7 +12,7 @@ Status key:
2. Freeze token taxonomy: `DONE` 2. Freeze token taxonomy: `DONE`
3. Finalize membership contract interface targets: `DONE` 3. Finalize membership contract interface targets: `DONE`
4. Lock signature + intent protocol: `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) 6. Implement membership gate in marketplace checkout: `IN_PROGRESS` (store scaffold + gate logic implemented; live API pending)
7. Ship offer registry schema: `DONE` 7. Ship offer registry schema: `DONE`
8. Ship entitlement purchase schema/pipeline contracts: `IN_PROGRESS` 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. 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. 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. 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: Remaining in this repo:
@ -61,10 +63,10 @@ Remaining in this repo:
Cross-repo dependencies (kernel/backend/contracts): Cross-repo dependencies (kernel/backend/contracts):
1. Implement `/secret/membership/quote` and `/secret/membership/confirm`. 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. 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. 3. Implement checkout APIs and entitlement mint pipeline.
4. Implement runtime entitlement gate and evidence receipts. 4. Implement runtime entitlement gate and evidence receipts.
5. Implement member app channel APIs and deterministic event stream storage. 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. 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. 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).