Add secretapi member channel endpoints and deployment hardening
This commit is contained in:
parent
bfc374b9ce
commit
27b5900205
28
backend/secretapi/.env.example
Normal file
28
backend/secretapi/.env.example
Normal 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
|
||||||
16
backend/secretapi/Dockerfile
Normal file
16
backend/secretapi/Dockerfile
Normal 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"]
|
||||||
|
|
||||||
98
backend/secretapi/README.md
Normal file
98
backend/secretapi/README.md
Normal 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
1359
backend/secretapi/app.go
Normal file
File diff suppressed because it is too large
Load Diff
564
backend/secretapi/app_test.go
Normal file
564
backend/secretapi/app_test.go
Normal 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)
|
||||||
|
}
|
||||||
75
backend/secretapi/chain.go
Normal file
75
backend/secretapi/chain.go
Normal 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")
|
||||||
|
}
|
||||||
83
backend/secretapi/config.go
Normal file
83
backend/secretapi/config.go
Normal 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
132
backend/secretapi/crypto.go
Normal 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
|
||||||
|
}
|
||||||
23
backend/secretapi/deploy/secretapi.service
Normal file
23
backend/secretapi/deploy/secretapi.service
Normal 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
39
backend/secretapi/go.mod
Normal 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
67
backend/secretapi/go.sum
Normal 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
32
backend/secretapi/main.go
Normal 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
383
backend/secretapi/models.go
Normal 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
788
backend/secretapi/store.go
Normal 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, ¬Null, &defaultVal, &pk); err != nil {
|
||||||
|
return fmt.Errorf("migrate scan table_info %s: %w", table, err)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(name, column) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("migrate iterate table_info %s: %w", table, err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx, "ALTER TABLE "+table+" ADD COLUMN "+column+" "+columnDef); err != nil {
|
||||||
|
return fmt.Errorf("migrate add column %s.%s: %w", table, column, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) putDesignation(ctx context.Context, rec designationRecord) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO designations (
|
||||||
|
code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(code) DO UPDATE SET
|
||||||
|
display_token=excluded.display_token,
|
||||||
|
intent_id=excluded.intent_id,
|
||||||
|
nonce=excluded.nonce,
|
||||||
|
origin=excluded.origin,
|
||||||
|
locale=excluded.locale,
|
||||||
|
address=excluded.address,
|
||||||
|
chain_id=excluded.chain_id,
|
||||||
|
issued_at=excluded.issued_at,
|
||||||
|
expires_at=excluded.expires_at,
|
||||||
|
verified_at=excluded.verified_at,
|
||||||
|
membership_status=excluded.membership_status,
|
||||||
|
membership_tx_hash=excluded.membership_tx_hash,
|
||||||
|
activated_at=excluded.activated_at
|
||||||
|
`, rec.Code, rec.DisplayToken, rec.IntentID, rec.Nonce, rec.Origin, rec.Locale, rec.Address, rec.ChainID, rec.IssuedAt.Format(time.RFC3339Nano), rec.ExpiresAt.Format(time.RFC3339Nano), formatNullableTime(rec.VerifiedAt), strings.ToLower(rec.MembershipStatus), nullableString(rec.MembershipTxHash), formatNullableTime(rec.ActivatedAt))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getDesignationByIntent(ctx context.Context, intentID string) (designationRecord, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at
|
||||||
|
FROM designations
|
||||||
|
WHERE intent_id = ?
|
||||||
|
`, intentID)
|
||||||
|
return scanDesignation(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getDesignationByCode(ctx context.Context, code string) (designationRecord, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at
|
||||||
|
FROM designations
|
||||||
|
WHERE code = ?
|
||||||
|
`, code)
|
||||||
|
return scanDesignation(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getDesignationByAddress(ctx context.Context, address string) (designationRecord, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT code, display_token, intent_id, nonce, origin, locale, address, chain_id, issued_at, expires_at, verified_at, membership_status, membership_tx_hash, activated_at
|
||||||
|
FROM designations
|
||||||
|
WHERE address = ?
|
||||||
|
ORDER BY issued_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, strings.ToLower(strings.TrimSpace(address)))
|
||||||
|
return scanDesignation(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDesignation(row interface{ Scan(dest ...any) error }) (designationRecord, error) {
|
||||||
|
var rec designationRecord
|
||||||
|
var issued, expires, verified, activated sql.NullString
|
||||||
|
var membershipTx sql.NullString
|
||||||
|
err := row.Scan(&rec.Code, &rec.DisplayToken, &rec.IntentID, &rec.Nonce, &rec.Origin, &rec.Locale, &rec.Address, &rec.ChainID, &issued, &expires, &verified, &rec.MembershipStatus, &membershipTx, &activated)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return designationRecord{}, errNotFound
|
||||||
|
}
|
||||||
|
return designationRecord{}, err
|
||||||
|
}
|
||||||
|
rec.IssuedAt = parseRFC3339Nullable(issued)
|
||||||
|
rec.ExpiresAt = parseRFC3339Nullable(expires)
|
||||||
|
rec.VerifiedAt = parseRFC3339Ptr(verified)
|
||||||
|
rec.MembershipTxHash = membershipTx.String
|
||||||
|
rec.ActivatedAt = parseRFC3339Ptr(activated)
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) putQuote(ctx context.Context, quote quoteRecord) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO quotes (
|
||||||
|
quote_id, designation_code, address, payer_address, chain_id, currency, amount_atomic, decimals, contract_address, method, calldata, value_hex, sponsorship_mode, sponsor_org_root_id, created_at, expires_at, confirmed_at, confirmed_tx_hash
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(quote_id) DO UPDATE SET
|
||||||
|
designation_code=excluded.designation_code,
|
||||||
|
address=excluded.address,
|
||||||
|
payer_address=excluded.payer_address,
|
||||||
|
chain_id=excluded.chain_id,
|
||||||
|
currency=excluded.currency,
|
||||||
|
amount_atomic=excluded.amount_atomic,
|
||||||
|
decimals=excluded.decimals,
|
||||||
|
contract_address=excluded.contract_address,
|
||||||
|
method=excluded.method,
|
||||||
|
calldata=excluded.calldata,
|
||||||
|
value_hex=excluded.value_hex,
|
||||||
|
sponsorship_mode=excluded.sponsorship_mode,
|
||||||
|
sponsor_org_root_id=excluded.sponsor_org_root_id,
|
||||||
|
created_at=excluded.created_at,
|
||||||
|
expires_at=excluded.expires_at,
|
||||||
|
confirmed_at=excluded.confirmed_at,
|
||||||
|
confirmed_tx_hash=excluded.confirmed_tx_hash
|
||||||
|
`, quote.QuoteID, quote.DesignationCode, quote.Address, nullableString(quote.PayerAddress), quote.ChainID, quote.Currency, quote.AmountAtomic, quote.Decimals, quote.ContractAddress, quote.Method, quote.Calldata, quote.ValueHex, strings.ToLower(strings.TrimSpace(quote.SponsorshipMode)), nullableString(quote.SponsorOrgRootID), quote.CreatedAt.Format(time.RFC3339Nano), quote.ExpiresAt.Format(time.RFC3339Nano), formatNullableTime(quote.ConfirmedAt), nullableString(quote.ConfirmedTxHash))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getQuote(ctx context.Context, quoteID string) (quoteRecord, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT quote_id, designation_code, address, payer_address, chain_id, currency, amount_atomic, decimals, contract_address, method, calldata, value_hex, sponsorship_mode, sponsor_org_root_id, created_at, expires_at, confirmed_at, confirmed_tx_hash
|
||||||
|
FROM quotes
|
||||||
|
WHERE quote_id = ?
|
||||||
|
`, quoteID)
|
||||||
|
var rec quoteRecord
|
||||||
|
var created, expires, confirmed sql.NullString
|
||||||
|
var confirmedTx, payerAddress, sponsorOrgRootID sql.NullString
|
||||||
|
err := row.Scan(
|
||||||
|
&rec.QuoteID,
|
||||||
|
&rec.DesignationCode,
|
||||||
|
&rec.Address,
|
||||||
|
&payerAddress,
|
||||||
|
&rec.ChainID,
|
||||||
|
&rec.Currency,
|
||||||
|
&rec.AmountAtomic,
|
||||||
|
&rec.Decimals,
|
||||||
|
&rec.ContractAddress,
|
||||||
|
&rec.Method,
|
||||||
|
&rec.Calldata,
|
||||||
|
&rec.ValueHex,
|
||||||
|
&rec.SponsorshipMode,
|
||||||
|
&sponsorOrgRootID,
|
||||||
|
&created,
|
||||||
|
&expires,
|
||||||
|
&confirmed,
|
||||||
|
&confirmedTx,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return quoteRecord{}, errNotFound
|
||||||
|
}
|
||||||
|
return quoteRecord{}, err
|
||||||
|
}
|
||||||
|
rec.PayerAddress = payerAddress.String
|
||||||
|
rec.SponsorOrgRootID = sponsorOrgRootID.String
|
||||||
|
rec.CreatedAt = parseRFC3339Nullable(created)
|
||||||
|
rec.ExpiresAt = parseRFC3339Nullable(expires)
|
||||||
|
rec.ConfirmedAt = parseRFC3339Ptr(confirmed)
|
||||||
|
rec.ConfirmedTxHash = confirmedTx.String
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) putGovernancePrincipal(ctx context.Context, rec governancePrincipalRecord) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO governance_principals (
|
||||||
|
wallet, org_root_id, principal_id, principal_role, entitlement_id, entitlement_status, access_class, availability_state, lease_expires_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(wallet) DO UPDATE SET
|
||||||
|
org_root_id=excluded.org_root_id,
|
||||||
|
principal_id=excluded.principal_id,
|
||||||
|
principal_role=excluded.principal_role,
|
||||||
|
entitlement_id=excluded.entitlement_id,
|
||||||
|
entitlement_status=excluded.entitlement_status,
|
||||||
|
access_class=excluded.access_class,
|
||||||
|
availability_state=excluded.availability_state,
|
||||||
|
lease_expires_at=excluded.lease_expires_at,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
`, rec.Wallet, rec.OrgRootID, rec.PrincipalID, rec.PrincipalRole, rec.EntitlementID, rec.EntitlementStatus, rec.AccessClass, rec.AvailabilityState, formatNullableTime(rec.LeaseExpiresAt), rec.UpdatedAt.UTC().Format(time.RFC3339Nano))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getGovernancePrincipal(ctx context.Context, wallet string) (governancePrincipalRecord, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT wallet, org_root_id, principal_id, principal_role, entitlement_id, entitlement_status, access_class, availability_state, lease_expires_at, updated_at
|
||||||
|
FROM governance_principals
|
||||||
|
WHERE wallet = ?
|
||||||
|
`, strings.ToLower(strings.TrimSpace(wallet)))
|
||||||
|
var rec governancePrincipalRecord
|
||||||
|
var leaseExpiresAt, updatedAt sql.NullString
|
||||||
|
err := row.Scan(&rec.Wallet, &rec.OrgRootID, &rec.PrincipalID, &rec.PrincipalRole, &rec.EntitlementID, &rec.EntitlementStatus, &rec.AccessClass, &rec.AvailabilityState, &leaseExpiresAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return governancePrincipalRecord{}, errNotFound
|
||||||
|
}
|
||||||
|
return governancePrincipalRecord{}, err
|
||||||
|
}
|
||||||
|
rec.LeaseExpiresAt = parseRFC3339Ptr(leaseExpiresAt)
|
||||||
|
rec.UpdatedAt = parseRFC3339Nullable(updatedAt)
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) putGovernanceInstallToken(ctx context.Context, rec governanceInstallTokenRecord) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO governance_install_tokens (
|
||||||
|
install_token, wallet, org_root_id, principal_id, principal_role, device_id, entitlement_id, package_hash, runtime_version, policy_hash, issued_at, expires_at, consumed_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(install_token) DO UPDATE SET
|
||||||
|
wallet=excluded.wallet,
|
||||||
|
org_root_id=excluded.org_root_id,
|
||||||
|
principal_id=excluded.principal_id,
|
||||||
|
principal_role=excluded.principal_role,
|
||||||
|
device_id=excluded.device_id,
|
||||||
|
entitlement_id=excluded.entitlement_id,
|
||||||
|
package_hash=excluded.package_hash,
|
||||||
|
runtime_version=excluded.runtime_version,
|
||||||
|
policy_hash=excluded.policy_hash,
|
||||||
|
issued_at=excluded.issued_at,
|
||||||
|
expires_at=excluded.expires_at,
|
||||||
|
consumed_at=excluded.consumed_at
|
||||||
|
`, rec.InstallToken, rec.Wallet, rec.OrgRootID, rec.PrincipalID, rec.PrincipalRole, rec.DeviceID, rec.EntitlementID, rec.PackageHash, rec.RuntimeVersion, rec.PolicyHash, rec.IssuedAt.UTC().Format(time.RFC3339Nano), rec.ExpiresAt.UTC().Format(time.RFC3339Nano), formatNullableTime(rec.ConsumedAt))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getGovernanceInstallToken(ctx context.Context, token string) (governanceInstallTokenRecord, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT install_token, wallet, org_root_id, principal_id, principal_role, device_id, entitlement_id, package_hash, runtime_version, policy_hash, issued_at, expires_at, consumed_at
|
||||||
|
FROM governance_install_tokens
|
||||||
|
WHERE install_token = ?
|
||||||
|
`, strings.TrimSpace(token))
|
||||||
|
var rec governanceInstallTokenRecord
|
||||||
|
var issuedAt, expiresAt, consumedAt sql.NullString
|
||||||
|
err := row.Scan(&rec.InstallToken, &rec.Wallet, &rec.OrgRootID, &rec.PrincipalID, &rec.PrincipalRole, &rec.DeviceID, &rec.EntitlementID, &rec.PackageHash, &rec.RuntimeVersion, &rec.PolicyHash, &issuedAt, &expiresAt, &consumedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return governanceInstallTokenRecord{}, errNotFound
|
||||||
|
}
|
||||||
|
return governanceInstallTokenRecord{}, err
|
||||||
|
}
|
||||||
|
rec.IssuedAt = parseRFC3339Nullable(issuedAt)
|
||||||
|
rec.ExpiresAt = parseRFC3339Nullable(expiresAt)
|
||||||
|
rec.ConsumedAt = parseRFC3339Ptr(consumedAt)
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) putGovernanceInstall(ctx context.Context, rec governanceInstallRecord) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO governance_installs (
|
||||||
|
install_token, wallet, device_id, entitlement_id, runtime_version, package_hash, policy_hash, launcher_receipt_ref, installed_at, activated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(install_token) DO UPDATE SET
|
||||||
|
wallet=excluded.wallet,
|
||||||
|
device_id=excluded.device_id,
|
||||||
|
entitlement_id=excluded.entitlement_id,
|
||||||
|
runtime_version=excluded.runtime_version,
|
||||||
|
package_hash=excluded.package_hash,
|
||||||
|
policy_hash=excluded.policy_hash,
|
||||||
|
launcher_receipt_ref=excluded.launcher_receipt_ref,
|
||||||
|
installed_at=excluded.installed_at,
|
||||||
|
activated_at=excluded.activated_at
|
||||||
|
`, rec.InstallToken, rec.Wallet, rec.DeviceID, rec.EntitlementID, rec.RuntimeVersion, rec.PackageHash, rec.PolicyHash, nullableString(rec.LauncherReceiptRef), rec.InstalledAt.UTC().Format(time.RFC3339Nano), rec.ActivatedAt.UTC().Format(time.RFC3339Nano))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getGovernanceInstallByToken(ctx context.Context, token string) (governanceInstallRecord, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT install_token, wallet, device_id, entitlement_id, runtime_version, package_hash, policy_hash, launcher_receipt_ref, installed_at, activated_at
|
||||||
|
FROM governance_installs
|
||||||
|
WHERE install_token = ?
|
||||||
|
`, strings.TrimSpace(token))
|
||||||
|
return scanGovernanceInstall(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getGovernanceInstallByDevice(ctx context.Context, wallet, deviceID string) (governanceInstallRecord, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT install_token, wallet, device_id, entitlement_id, runtime_version, package_hash, policy_hash, launcher_receipt_ref, installed_at, activated_at
|
||||||
|
FROM governance_installs
|
||||||
|
WHERE wallet = ? AND device_id = ?
|
||||||
|
ORDER BY activated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(deviceID))
|
||||||
|
return scanGovernanceInstall(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanGovernanceInstall(row interface{ Scan(dest ...any) error }) (governanceInstallRecord, error) {
|
||||||
|
var rec governanceInstallRecord
|
||||||
|
var launcherReceiptRef, installedAt, activatedAt sql.NullString
|
||||||
|
err := row.Scan(&rec.InstallToken, &rec.Wallet, &rec.DeviceID, &rec.EntitlementID, &rec.RuntimeVersion, &rec.PackageHash, &rec.PolicyHash, &launcherReceiptRef, &installedAt, &activatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return governanceInstallRecord{}, errNotFound
|
||||||
|
}
|
||||||
|
return governanceInstallRecord{}, err
|
||||||
|
}
|
||||||
|
rec.LauncherReceiptRef = launcherReceiptRef.String
|
||||||
|
rec.InstalledAt = parseRFC3339Nullable(installedAt)
|
||||||
|
rec.ActivatedAt = parseRFC3339Nullable(activatedAt)
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) putMemberChannelBinding(ctx context.Context, rec memberChannelBindingRecord) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO member_channel_bindings (
|
||||||
|
channel_binding_id, wallet, chain_id, device_id, platform, org_root_id, principal_id, principal_role, app_version, push_provider, push_token, status, created_at, updated_at, removed_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(wallet, device_id) DO UPDATE SET
|
||||||
|
channel_binding_id=excluded.channel_binding_id,
|
||||||
|
chain_id=excluded.chain_id,
|
||||||
|
platform=excluded.platform,
|
||||||
|
org_root_id=excluded.org_root_id,
|
||||||
|
principal_id=excluded.principal_id,
|
||||||
|
principal_role=excluded.principal_role,
|
||||||
|
app_version=excluded.app_version,
|
||||||
|
push_provider=excluded.push_provider,
|
||||||
|
push_token=excluded.push_token,
|
||||||
|
status=excluded.status,
|
||||||
|
updated_at=excluded.updated_at,
|
||||||
|
removed_at=excluded.removed_at
|
||||||
|
`, rec.ChannelBindingID, rec.Wallet, rec.ChainID, rec.DeviceID, rec.Platform, rec.OrgRootID, rec.PrincipalID, rec.PrincipalRole, rec.AppVersion, rec.PushProvider, nullableString(rec.PushToken), rec.Status, rec.CreatedAt.UTC().Format(time.RFC3339Nano), rec.UpdatedAt.UTC().Format(time.RFC3339Nano), formatNullableTime(rec.RemovedAt))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getMemberChannelBinding(ctx context.Context, wallet, deviceID string) (memberChannelBindingRecord, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT channel_binding_id, wallet, chain_id, device_id, platform, org_root_id, principal_id, principal_role, app_version, push_provider, push_token, status, created_at, updated_at, removed_at
|
||||||
|
FROM member_channel_bindings
|
||||||
|
WHERE wallet = ? AND device_id = ? AND status = 'active'
|
||||||
|
`, strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(deviceID))
|
||||||
|
return scanMemberChannelBinding(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getMemberChannelBindingByPrincipal(ctx context.Context, wallet, orgRootID, principalID string) (memberChannelBindingRecord, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT channel_binding_id, wallet, chain_id, device_id, platform, org_root_id, principal_id, principal_role, app_version, push_provider, push_token, status, created_at, updated_at, removed_at
|
||||||
|
FROM member_channel_bindings
|
||||||
|
WHERE wallet = ? AND org_root_id = ? AND principal_id = ? AND status = 'active'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(orgRootID), strings.TrimSpace(principalID))
|
||||||
|
return scanMemberChannelBinding(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanMemberChannelBinding(row interface{ Scan(dest ...any) error }) (memberChannelBindingRecord, error) {
|
||||||
|
var rec memberChannelBindingRecord
|
||||||
|
var pushToken, createdAt, updatedAt, removedAt sql.NullString
|
||||||
|
err := row.Scan(
|
||||||
|
&rec.ChannelBindingID,
|
||||||
|
&rec.Wallet,
|
||||||
|
&rec.ChainID,
|
||||||
|
&rec.DeviceID,
|
||||||
|
&rec.Platform,
|
||||||
|
&rec.OrgRootID,
|
||||||
|
&rec.PrincipalID,
|
||||||
|
&rec.PrincipalRole,
|
||||||
|
&rec.AppVersion,
|
||||||
|
&rec.PushProvider,
|
||||||
|
&pushToken,
|
||||||
|
&rec.Status,
|
||||||
|
&createdAt,
|
||||||
|
&updatedAt,
|
||||||
|
&removedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return memberChannelBindingRecord{}, errNotFound
|
||||||
|
}
|
||||||
|
return memberChannelBindingRecord{}, err
|
||||||
|
}
|
||||||
|
rec.PushToken = pushToken.String
|
||||||
|
rec.CreatedAt = parseRFC3339Nullable(createdAt)
|
||||||
|
rec.UpdatedAt = parseRFC3339Nullable(updatedAt)
|
||||||
|
rec.RemovedAt = parseRFC3339Ptr(removedAt)
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) removeMemberChannelBinding(ctx context.Context, wallet, deviceID string, removedAt time.Time) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `
|
||||||
|
UPDATE member_channel_bindings
|
||||||
|
SET status = 'removed', removed_at = ?, updated_at = ?
|
||||||
|
WHERE wallet = ? AND device_id = ? AND status = 'active'
|
||||||
|
`, removedAt.UTC().Format(time.RFC3339Nano), removedAt.UTC().Format(time.RFC3339Nano), strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(deviceID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return errNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) putMemberChannelEvent(ctx context.Context, rec memberChannelEventRecord) error {
|
||||||
|
if strings.TrimSpace(rec.EventID) == "" {
|
||||||
|
id, err := randomHex(8)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rec.EventID = "evt_" + id
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO member_channel_events (
|
||||||
|
event_id, wallet, org_root_id, principal_id, class, created_at, title, body, dedupe_key, requires_ack, policy_hash, payload_json, visibility_scope
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(wallet, org_root_id, dedupe_key) DO NOTHING
|
||||||
|
`, rec.EventID, strings.ToLower(strings.TrimSpace(rec.Wallet)), strings.TrimSpace(rec.OrgRootID), strings.TrimSpace(rec.PrincipalID), strings.TrimSpace(rec.Class), rec.CreatedAt.UTC().Format(time.RFC3339Nano), rec.Title, rec.Body, rec.DedupeKey, boolToInt(rec.RequiresAck), rec.PolicyHash, rec.PayloadJSON, strings.TrimSpace(rec.VisibilityScope))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getMemberChannelEventSeqByID(ctx context.Context, eventID string) (int64, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT seq
|
||||||
|
FROM member_channel_events
|
||||||
|
WHERE event_id = ?
|
||||||
|
`, strings.TrimSpace(eventID))
|
||||||
|
var seq int64
|
||||||
|
if err := row.Scan(&seq); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, errNotFound
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return seq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) listMemberChannelEvents(ctx context.Context, wallet, orgRootID string, includeOwnerAdmin bool, cursor string, limit int) ([]memberChannelEventRecord, string, error) {
|
||||||
|
cursorSeq := int64(0)
|
||||||
|
if strings.TrimSpace(cursor) != "" {
|
||||||
|
seq, err := s.getMemberChannelEventSeqByID(ctx, cursor)
|
||||||
|
if err == nil {
|
||||||
|
cursorSeq = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visibilityClause := "visibility_scope = 'member'"
|
||||||
|
if includeOwnerAdmin {
|
||||||
|
visibilityClause = "(visibility_scope = 'member' OR visibility_scope = 'owner_admin')"
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT seq, event_id, wallet, org_root_id, principal_id, class, created_at, title, body, dedupe_key, requires_ack, policy_hash, payload_json, visibility_scope
|
||||||
|
FROM member_channel_events
|
||||||
|
WHERE wallet = ? AND org_root_id = ? AND seq > ? AND `+visibilityClause+`
|
||||||
|
ORDER BY seq ASC
|
||||||
|
LIMIT ?
|
||||||
|
`, strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(orgRootID), cursorSeq, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
events := make([]memberChannelEventRecord, 0, limit)
|
||||||
|
nextCursor := ""
|
||||||
|
for rows.Next() {
|
||||||
|
var rec memberChannelEventRecord
|
||||||
|
var createdAt sql.NullString
|
||||||
|
var requiresAck int
|
||||||
|
if err := rows.Scan(
|
||||||
|
&rec.Seq,
|
||||||
|
&rec.EventID,
|
||||||
|
&rec.Wallet,
|
||||||
|
&rec.OrgRootID,
|
||||||
|
&rec.PrincipalID,
|
||||||
|
&rec.Class,
|
||||||
|
&createdAt,
|
||||||
|
&rec.Title,
|
||||||
|
&rec.Body,
|
||||||
|
&rec.DedupeKey,
|
||||||
|
&requiresAck,
|
||||||
|
&rec.PolicyHash,
|
||||||
|
&rec.PayloadJSON,
|
||||||
|
&rec.VisibilityScope,
|
||||||
|
); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
rec.CreatedAt = parseRFC3339Nullable(createdAt)
|
||||||
|
rec.RequiresAck = requiresAck == 1
|
||||||
|
events = append(events, rec)
|
||||||
|
nextCursor = rec.EventID
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return events, nextCursor, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getMemberChannelEventByID(ctx context.Context, eventID string) (memberChannelEventRecord, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT seq, event_id, wallet, org_root_id, principal_id, class, created_at, title, body, dedupe_key, requires_ack, policy_hash, payload_json, visibility_scope
|
||||||
|
FROM member_channel_events
|
||||||
|
WHERE event_id = ?
|
||||||
|
`, strings.TrimSpace(eventID))
|
||||||
|
var rec memberChannelEventRecord
|
||||||
|
var createdAt sql.NullString
|
||||||
|
var requiresAck int
|
||||||
|
err := row.Scan(&rec.Seq, &rec.EventID, &rec.Wallet, &rec.OrgRootID, &rec.PrincipalID, &rec.Class, &createdAt, &rec.Title, &rec.Body, &rec.DedupeKey, &requiresAck, &rec.PolicyHash, &rec.PayloadJSON, &rec.VisibilityScope)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return memberChannelEventRecord{}, errNotFound
|
||||||
|
}
|
||||||
|
return memberChannelEventRecord{}, err
|
||||||
|
}
|
||||||
|
rec.CreatedAt = parseRFC3339Nullable(createdAt)
|
||||||
|
rec.RequiresAck = requiresAck == 1
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) putMemberChannelEventAck(ctx context.Context, eventID, wallet, deviceID string, acknowledgedAt time.Time) (time.Time, error) {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO member_channel_event_acks (event_id, wallet, device_id, acknowledged_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(event_id, wallet, device_id) DO NOTHING
|
||||||
|
`, strings.TrimSpace(eventID), strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(deviceID), acknowledgedAt.UTC().Format(time.RFC3339Nano))
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT acknowledged_at
|
||||||
|
FROM member_channel_event_acks
|
||||||
|
WHERE event_id = ? AND wallet = ? AND device_id = ?
|
||||||
|
`, strings.TrimSpace(eventID), strings.ToLower(strings.TrimSpace(wallet)), strings.TrimSpace(deviceID))
|
||||||
|
var ackRaw sql.NullString
|
||||||
|
if err := row.Scan(&ackRaw); err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return parseRFC3339Nullable(ackRaw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) putMemberChannelSupportTicket(ctx context.Context, rec memberChannelSupportTicketRecord) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO member_support_tickets (
|
||||||
|
ticket_id, wallet, org_root_id, principal_id, category, summary, context_json, status, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, rec.TicketID, strings.ToLower(strings.TrimSpace(rec.Wallet)), strings.TrimSpace(rec.OrgRootID), strings.TrimSpace(rec.PrincipalID), strings.TrimSpace(rec.Category), rec.Summary, rec.ContextJSON, strings.TrimSpace(rec.Status), rec.CreatedAt.UTC().Format(time.RFC3339Nano))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRFC3339Nullable(raw sql.NullString) time.Time {
|
||||||
|
if !raw.Valid || strings.TrimSpace(raw.String) == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
ts, err := time.Parse(time.RFC3339Nano, raw.String)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return ts.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRFC3339Ptr(raw sql.NullString) *time.Time {
|
||||||
|
ts := parseRFC3339Nullable(raw)
|
||||||
|
if ts.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatNullableTime(ts *time.Time) any {
|
||||||
|
if ts == nil || ts.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ts.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableString(v string) any {
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(v bool) int {
|
||||||
|
if v {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
77
docs/deployment/secretapi-deploy.md
Normal file
77
docs/deployment/secretapi-deploy.md
Normal 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.
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user