Add wallet session lifecycle endpoints and docs

This commit is contained in:
Joshua 2026-02-18 20:42:04 -08:00
parent a711ba36b6
commit f1eb34f5b3
9 changed files with 711 additions and 9 deletions

View File

@ -29,6 +29,8 @@ Copy `.env.example` in this folder and set contract/runtime values before deploy
- `POST /secret/wallet/intent`
- `POST /secret/wallet/verify`
- `POST /secret/wallet/session/refresh`
- `POST /secret/wallet/session/revoke`
- `POST /secret/membership/quote`
- `POST /secret/membership/confirm`
- `GET /secret/membership/status`
@ -71,6 +73,11 @@ When `SECRET_API_REQUIRE_WALLET_SESSION=true`, wallet-scoped control-plane endpo
Covered endpoints include marketplace checkout/entitlements, governance install/lease actions, and member-channel calls.
Session lifecycle endpoints:
1. `POST /secret/wallet/session/refresh`: rotates the current session token and revokes the prior token.
2. `POST /secret/wallet/session/revoke`: revokes the current token immediately.
## Sponsorship Behavior
Membership quote supports ownership wallet and distinct payer wallet:

View File

@ -34,6 +34,8 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("/healthz", a.withCORS(a.handleHealth))
mux.HandleFunc("/secret/wallet/intent", a.withCORS(a.handleWalletIntent))
mux.HandleFunc("/secret/wallet/verify", a.withCORS(a.handleWalletVerify))
mux.HandleFunc("/secret/wallet/session/refresh", a.withCORS(a.handleWalletSessionRefresh))
mux.HandleFunc("/secret/wallet/session/revoke", a.withCORS(a.handleWalletSessionRevoke))
mux.HandleFunc("/secret/membership/quote", a.withCORS(a.handleMembershipQuote))
mux.HandleFunc("/secret/membership/confirm", a.withCORS(a.handleMembershipConfirm))
mux.HandleFunc("/secret/membership/status", a.withCORS(a.handleMembershipStatus))
@ -230,6 +232,97 @@ func (a *app) handleWalletVerify(w http.ResponseWriter, r *http.Request) {
})
}
func (a *app) handleWalletSessionRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req walletSessionRefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
oldToken := sessionTokenFromRequest(r)
if strings.TrimSpace(oldToken) == "" {
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_required", "wallet session required")
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
oldSession, err := a.store.getWalletSession(r.Context(), oldToken)
if err != nil {
if err == errNotFound {
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_invalid", "wallet session not found")
return
}
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve wallet session")
return
}
now := time.Now().UTC()
if err := a.store.revokeWalletSession(r.Context(), oldToken, now); err != nil && err != errNotFound {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to revoke old wallet session")
return
}
newSession, err := a.issueWalletSession(r.Context(), wallet, oldSession.DesignationCode)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "session_issue_failed", "failed to issue wallet session")
return
}
w.Header().Set(sessionHeaderToken, newSession.SessionToken)
w.Header().Set(sessionHeaderExpiresAt, newSession.ExpiresAt.UTC().Format(time.RFC3339Nano))
writeJSON(w, http.StatusOK, walletSessionRefreshResponse{
Status: "session_refreshed",
Wallet: wallet,
SessionToken: newSession.SessionToken,
SessionExpire: newSession.ExpiresAt.UTC().Format(time.RFC3339Nano),
})
}
func (a *app) handleWalletSessionRevoke(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req walletSessionRevokeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
sessionToken := sessionTokenFromRequest(r)
if strings.TrimSpace(sessionToken) == "" {
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_required", "wallet session required")
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
now := time.Now().UTC()
if err := a.store.revokeWalletSession(r.Context(), sessionToken, now); err != nil {
if err == errNotFound {
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_invalid", "wallet session not found")
return
}
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to revoke wallet session")
return
}
writeJSON(w, http.StatusOK, walletSessionRevokeResponse{
Status: "session_revoked",
Wallet: wallet,
RevokedAt: now.Format(time.RFC3339Nano),
})
}
func (a *app) handleMembershipQuote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
@ -1476,4 +1569,7 @@ func isTxHash(value string) bool {
func logConfig(cfg Config) {
log.Printf("secret api listening on %s chain_id=%d contract=%s currency=%s amount_atomic=%s", cfg.ListenAddr, cfg.ChainID, cfg.MembershipContract, cfg.MintCurrency, cfg.MintAmountAtomic)
if !cfg.RequireWalletSession {
log.Printf("warning: wallet session enforcement is disabled (SECRET_API_REQUIRE_WALLET_SESSION=false)")
}
}

View File

@ -1210,6 +1210,314 @@ func TestWalletSessionMismatchBlocked(t *testing.T) {
}
}
func TestWalletVerifyIssuesSessionToken(t *testing.T) {
a, cfg, cleanup := newTestApp(t)
defer cleanup()
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.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 strings.TrimSpace(verifyRes.SessionToken) == "" {
t.Fatalf("expected wallet verify to issue session token: %+v", verifyRes)
}
if strings.TrimSpace(verifyRes.SessionExpiresAt) == "" {
t.Fatalf("expected wallet verify to return session expiry: %+v", verifyRes)
}
}
func TestWalletSessionInvalidBlocked(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
a.cfg.RequireWalletSession = true
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
t.Fatalf("seed active membership: %v", err)
}
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
Wallet: ownerAddr,
OfferID: offerIDSoloCore,
PrincipalRole: "org_root_owner",
}, http.StatusUnauthorized, map[string]string{
sessionHeaderToken: "deadbeef",
})
if code := errResp["code"]; code != "wallet_session_invalid" {
t.Fatalf("expected wallet_session_invalid, got %+v", errResp)
}
}
func TestWalletSessionExpiredBlocked(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
a.cfg.RequireWalletSession = true
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
t.Fatalf("seed active membership: %v", err)
}
now := time.Now().UTC()
session := walletSessionRecord{
SessionToken: "expired-session-token",
Wallet: ownerAddr,
DesignationCode: "1234567890123",
ChainID: a.cfg.ChainID,
IssuedAt: now.Add(-2 * time.Hour),
ExpiresAt: now.Add(-1 * time.Minute),
}
if err := a.store.putWalletSession(context.Background(), session); err != nil {
t.Fatalf("seed wallet session: %v", err)
}
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
Wallet: ownerAddr,
OfferID: offerIDSoloCore,
PrincipalRole: "org_root_owner",
}, http.StatusUnauthorized, map[string]string{
sessionHeaderToken: session.SessionToken,
})
if code := errResp["code"]; code != "wallet_session_expired" {
t.Fatalf("expected wallet_session_expired, got %+v", errResp)
}
}
func TestWalletSessionRevokedBlocked(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
a.cfg.RequireWalletSession = true
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
t.Fatalf("seed active membership: %v", err)
}
now := time.Now().UTC()
session := walletSessionRecord{
SessionToken: "revoked-session-token",
Wallet: ownerAddr,
DesignationCode: "1234567890123",
ChainID: a.cfg.ChainID,
IssuedAt: now.Add(-1 * time.Hour),
ExpiresAt: now.Add(1 * time.Hour),
RevokedAt: &now,
}
if err := a.store.putWalletSession(context.Background(), session); err != nil {
t.Fatalf("seed wallet session: %v", err)
}
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
Wallet: ownerAddr,
OfferID: offerIDSoloCore,
PrincipalRole: "org_root_owner",
}, http.StatusUnauthorized, map[string]string{
sessionHeaderToken: session.SessionToken,
})
if code := errResp["code"]; code != "wallet_session_revoked" {
t.Fatalf("expected wallet_session_revoked, got %+v", errResp)
}
}
func TestWalletSessionRefreshRotatesToken(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
a.cfg.RequireWalletSession = true
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
t.Fatalf("seed active membership: %v", err)
}
session, err := a.issueWalletSession(context.Background(), ownerAddr, "1234567890123")
if err != nil {
t.Fatalf("issue wallet session: %v", err)
}
refresh := postJSONExpectWithHeaders[walletSessionRefreshResponse](t, a, "/secret/wallet/session/refresh", walletSessionRefreshRequest{
Wallet: ownerAddr,
}, http.StatusOK, map[string]string{
sessionHeaderToken: session.SessionToken,
})
if refresh.Status != "session_refreshed" {
t.Fatalf("expected refreshed status, got %+v", refresh)
}
if strings.TrimSpace(refresh.SessionToken) == "" || refresh.SessionToken == session.SessionToken {
t.Fatalf("expected rotated token, got %+v", refresh)
}
oldBlocked := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
Wallet: ownerAddr,
OfferID: offerIDSoloCore,
PrincipalRole: "org_root_owner",
}, http.StatusUnauthorized, map[string]string{
sessionHeaderToken: session.SessionToken,
})
if code := oldBlocked["code"]; code != "wallet_session_revoked" {
t.Fatalf("expected old token revoked, got %+v", oldBlocked)
}
newAllowed := postJSONExpectWithHeaders[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
Wallet: ownerAddr,
OfferID: offerIDSoloCore,
PrincipalRole: "org_root_owner",
}, http.StatusOK, map[string]string{
sessionHeaderToken: refresh.SessionToken,
})
if strings.TrimSpace(newAllowed.QuoteID) == "" {
t.Fatalf("expected quote with refreshed token")
}
}
func TestWalletSessionRevokeEndpointBlocksFurtherUse(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
a.cfg.RequireWalletSession = true
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
t.Fatalf("seed active membership: %v", err)
}
session, err := a.issueWalletSession(context.Background(), ownerAddr, "1234567890123")
if err != nil {
t.Fatalf("issue wallet session: %v", err)
}
revoked := postJSONExpectWithHeaders[walletSessionRevokeResponse](t, a, "/secret/wallet/session/revoke", walletSessionRevokeRequest{
Wallet: ownerAddr,
}, http.StatusOK, map[string]string{
sessionHeaderToken: session.SessionToken,
})
if revoked.Status != "session_revoked" {
t.Fatalf("unexpected revoke response: %+v", revoked)
}
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
Wallet: ownerAddr,
OfferID: offerIDSoloCore,
PrincipalRole: "org_root_owner",
}, http.StatusUnauthorized, map[string]string{
sessionHeaderToken: session.SessionToken,
})
if code := errResp["code"]; code != "wallet_session_revoked" {
t.Fatalf("expected revoked token to fail, got %+v", errResp)
}
}
func TestMemberChannelRegisterRequiresWalletSession(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
a.cfg.RequireWalletSession = true
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
t.Fatalf("seed active membership: %v", err)
}
errResp := postJSONExpect[map[string]string](t, a, "/member/channel/device/register", memberChannelDeviceRegisterRequest{
Wallet: ownerAddr,
ChainID: a.cfg.ChainID,
DeviceID: "desktop-local-01",
Platform: "desktop",
OrgRootID: "org_test",
PrincipalID: "principal_test",
PrincipalRole: "org_root_owner",
AppVersion: "0.1.0",
PushProvider: "none",
}, http.StatusUnauthorized)
if code := errResp["code"]; code != "wallet_session_required" {
t.Fatalf("expected wallet_session_required, got %+v", errResp)
}
}
func TestGovernanceInstallTokenRequiresWalletSession(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
a.cfg.RequireWalletSession = true
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
t.Fatalf("seed active membership: %v", err)
}
now := time.Now().UTC()
if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{
Wallet: ownerAddr,
OrgRootID: "org_test",
PrincipalID: "principal_test",
PrincipalRole: "org_root_owner",
EntitlementID: "ent_test",
EntitlementStatus: "active",
AccessClass: "connected",
AvailabilityState: "active",
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed governance principal: %v", err)
}
errResp := postJSONExpect[map[string]string](t, a, "/governance/install/token", governanceInstallTokenRequest{
Wallet: ownerAddr,
OrgRootID: "org_test",
PrincipalID: "principal_test",
PrincipalRole: "org_root_owner",
DeviceID: "device-test",
LauncherVersion: "0.1.0",
Platform: "desktop",
}, http.StatusUnauthorized)
if code := errResp["code"]; code != "wallet_session_required" {
t.Fatalf("expected wallet_session_required, got %+v", errResp)
}
}
func TestIssueWalletSessionPrunesExpired(t *testing.T) {
a, _, cleanup := newTestApp(t)
defer cleanup()
ownerKey := mustKey(t)
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
now := time.Now().UTC()
expired := walletSessionRecord{
SessionToken: "prune-me",
Wallet: ownerAddr,
DesignationCode: "1111111111111",
ChainID: a.cfg.ChainID,
IssuedAt: now.Add(-3 * time.Hour),
ExpiresAt: now.Add(-2 * time.Hour),
}
if err := a.store.putWalletSession(context.Background(), expired); err != nil {
t.Fatalf("seed expired session: %v", err)
}
if _, err := a.issueWalletSession(context.Background(), ownerAddr, "2222222222222"); err != nil {
t.Fatalf("issue wallet session: %v", err)
}
_, err := a.store.getWalletSession(context.Background(), expired.SessionToken)
if err != errNotFound {
t.Fatalf("expected expired session to be pruned, got err=%v", err)
}
}
type tWalletIntentResponse struct {
IntentID string `json:"intent_id"`
DesignationCode string `json:"designation_code"`
@ -1222,6 +1530,7 @@ type tWalletVerifyResponse struct {
Status string `json:"status"`
DesignationCode string `json:"designation_code"`
SessionToken string `json:"session_token"`
SessionExpiresAt string `json:"session_expires_at"`
}
func newTestApp(t *testing.T) (*app, Config, func()) {

View File

@ -37,6 +37,27 @@ type walletVerifyResponse struct {
SessionExpires string `json:"session_expires_at,omitempty"`
}
type walletSessionRefreshRequest struct {
Wallet string `json:"wallet"`
}
type walletSessionRefreshResponse struct {
Status string `json:"status"`
Wallet string `json:"wallet"`
SessionToken string `json:"session_token"`
SessionExpire string `json:"session_expires_at"`
}
type walletSessionRevokeRequest struct {
Wallet string `json:"wallet"`
}
type walletSessionRevokeResponse struct {
Status string `json:"status"`
Wallet string `json:"wallet"`
RevokedAt string `json:"revoked_at"`
}
type membershipQuoteRequest struct {
DesignationCode string `json:"designation_code"`
Address string `json:"address"`

View File

@ -13,6 +13,7 @@ const (
)
func (a *app) issueWalletSession(ctx context.Context, wallet, designationCode string) (walletSessionRecord, error) {
_, _ = a.store.deleteExpiredWalletSessions(ctx, time.Now().UTC())
token, err := randomHex(24)
if err != nil {
return walletSessionRecord{}, err

View File

@ -453,6 +453,40 @@ func (s *store) touchWalletSession(ctx context.Context, token string, touchedAt
return nil
}
func (s *store) revokeWalletSession(ctx context.Context, token string, revokedAt time.Time) error {
res, err := s.db.ExecContext(ctx, `
UPDATE wallet_sessions
SET revoked_at = ?
WHERE session_token = ?
`, revokedAt.UTC().Format(time.RFC3339Nano), strings.TrimSpace(token))
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return errNotFound
}
return nil
}
func (s *store) deleteExpiredWalletSessions(ctx context.Context, now time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx, `
DELETE FROM wallet_sessions
WHERE expires_at <= ?
`, now.UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, err
}
affected, err := res.RowsAffected()
if err != nil {
return 0, err
}
return affected, nil
}
func (s *store) putQuote(ctx context.Context, quote quoteRecord) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO quotes (

View File

@ -80,7 +80,65 @@ Error (`400` intent expired):
}
```
## 3) `POST /secret/membership/quote`
## 3) `POST /secret/wallet/session/refresh`
Request:
Headers:
1. `Authorization: Bearer <session_token>` (or `X-Edut-Session`)
```json
{
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5"
}
```
Success (`200`):
```json
{
"status": "session_refreshed",
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
"session_token": "f9bc20f15ecf7fd53f1f4ba8ca774564a1098e6ed9db6f0f",
"session_expires_at": "2026-03-18T07:42:10Z"
}
```
Error (`401` missing/expired token):
```json
{
"error": "wallet session required",
"code": "wallet_session_required"
}
```
## 4) `POST /secret/wallet/session/revoke`
Request:
Headers:
1. `Authorization: Bearer <session_token>` (or `X-Edut-Session`)
```json
{
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5"
}
```
Success (`200`):
```json
{
"status": "session_revoked",
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
"revoked_at": "2026-02-17T07:34:02Z"
}
```
## 5) `POST /secret/membership/quote`
Request:
@ -142,7 +200,7 @@ Error (`403` distinct payer without proof):
}
```
## 4) `POST /secret/membership/confirm`
## 6) `POST /secret/membership/confirm`
Request:
@ -184,7 +242,7 @@ Error (`400` tx mismatch):
}
```
## 5) `GET /secret/membership/status`
## 7) `GET /secret/membership/status`
Request by wallet:

View File

@ -34,10 +34,70 @@ paths:
responses:
'200':
description: Signature verified
headers:
X-Edut-Session:
schema:
type: string
description: Wallet session token for follow-on wallet-scoped APIs.
X-Edut-Session-Expires-At:
schema:
type: string
format: date-time
description: Session expiry timestamp.
content:
application/json:
schema:
$ref: '#/components/schemas/WalletVerifyResponse'
/secret/wallet/session/refresh:
post:
summary: Rotate wallet session token
description: Requires a valid wallet session token (`Authorization: Bearer` or `X-Edut-Session`).
parameters:
- $ref: '#/components/parameters/WalletSessionHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WalletSessionRefreshRequest'
responses:
'200':
description: Session rotated
headers:
X-Edut-Session:
schema:
type: string
X-Edut-Session-Expires-At:
schema:
type: string
format: date-time
content:
application/json:
schema:
$ref: '#/components/schemas/WalletSessionRefreshResponse'
'401':
description: Session missing, invalid, revoked, or expired
/secret/wallet/session/revoke:
post:
summary: Revoke wallet session token
description: Requires a valid wallet session token (`Authorization: Bearer` or `X-Edut-Session`).
parameters:
- $ref: '#/components/parameters/WalletSessionHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WalletSessionRevokeRequest'
responses:
'200':
description: Session revoked
content:
application/json:
schema:
$ref: '#/components/schemas/WalletSessionRevokeResponse'
'401':
description: Session missing, invalid, revoked, or expired
/secret/membership/quote:
post:
summary: Get current membership mint quote
@ -93,6 +153,14 @@ paths:
schema:
$ref: '#/components/schemas/MembershipStatusResponse'
components:
parameters:
WalletSessionHeader:
in: header
name: X-Edut-Session
required: false
schema:
type: string
description: Wallet session token. `Authorization: Bearer <token>` is also accepted.
schemas:
WalletIntentRequest:
type: object
@ -166,6 +234,46 @@ components:
type: string
format: date-time
description: Session token expiry timestamp.
WalletSessionRefreshRequest:
type: object
required: [wallet]
properties:
wallet:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
WalletSessionRefreshResponse:
type: object
required: [status, wallet, session_token, session_expires_at]
properties:
status:
type: string
enum: [session_refreshed]
wallet:
type: string
session_token:
type: string
session_expires_at:
type: string
format: date-time
WalletSessionRevokeRequest:
type: object
required: [wallet]
properties:
wallet:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
WalletSessionRevokeResponse:
type: object
required: [status, wallet, revoked_at]
properties:
status:
type: string
enum: [session_revoked]
wallet:
type: string
revoked_at:
type: string
format: date-time
MembershipQuoteRequest:
type: object
required: [designation_code, address, chain_id]

View File

@ -63,6 +63,8 @@ Post-mint success -> app download links (Desktop/iOS/Android)
| Landing UI | `edut.ai`, `edut.dev` | Continue flow + wallet intent/signature + membership mint UX |
| API | `api.edut.ai/secret/wallet/intent` | Create one-time designation intent |
| API | `api.edut.ai/secret/wallet/verify` | Verify signature and bind wallet identity |
| API | `api.edut.ai/secret/wallet/session/refresh` | Rotate wallet session token |
| API | `api.edut.ai/secret/wallet/session/revoke` | Revoke wallet session token |
| API | `api.edut.ai/secret/membership/quote` | Return current payable membership quote |
| API | `api.edut.ai/secret/membership/confirm` | Confirm membership tx and activation state |
| Chain | Base | Membership mint settlement and evidence |
@ -160,11 +162,77 @@ Response:
"status": "signature_verified",
"designation_code": "0217073045482",
"display_token": "0217-0730-4548-2",
"verified_at": "2026-02-17T07:31:12Z"
"verified_at": "2026-02-17T07:31:12Z",
"session_token": "9f2c50f8a0f5d8d0b0efc4fa665e4032f31bb0c4c4f31b8c",
"session_expires_at": "2026-03-18T07:31:12Z"
}
```
### 3) Membership Quote
### 3) Wallet Session Lifecycle
#### `POST /secret/wallet/session/refresh`
Request JSON:
```json
{
"wallet": "0xabc123..."
}
```
Requirements:
1. Valid wallet session token via `Authorization: Bearer <token>` or `X-Edut-Session`.
2. Session wallet must match request wallet.
Behavior:
1. Validate current token state (not expired/revoked, chain matches).
2. Revoke the presented token.
3. Issue a replacement token with fresh expiry.
Response:
```json
{
"status": "session_refreshed",
"wallet": "0xabc123...",
"session_token": "f9bc20f15ecf7fd53f1f4ba8ca774564a1098e6ed9db6f0f",
"session_expires_at": "2026-03-18T07:42:10Z"
}
```
#### `POST /secret/wallet/session/revoke`
Request JSON:
```json
{
"wallet": "0xabc123..."
}
```
Requirements:
1. Valid wallet session token via `Authorization: Bearer <token>` or `X-Edut-Session`.
2. Session wallet must match request wallet.
Behavior:
1. Validate current token state.
2. Mark session as revoked immediately.
Response:
```json
{
"status": "session_revoked",
"wallet": "0xabc123...",
"revoked_at": "2026-02-17T07:34:02Z"
}
```
### 4) Membership Quote
#### `POST /secret/membership/quote`
@ -200,7 +268,7 @@ Response:
}
```
### 4) Membership Confirm
### 5) Membership Confirm
#### `POST /secret/membership/confirm`