clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

fix: recheck callback grants and source visibility before delivery

Dami committed a99bc44
Require an active scoped agent grant and a current readable stored event for each delivery.
Preserve ephemeral event delivery through its signed kind and redact callback URLs consistently.
Cover revoked and expired grants, source deletion and expiry, narrowed scopes, and valid deliveries.
commit
a99bc44c66522cb45313b9b84b7fc100b4dca5a7
parent
9e4b39518f36996a148d83468ed130c4ba7e8563
author
Dami <dami.etoile@gmail.com>
5 files changed, +335 -13
Mdocs/agents.md2+-
Mdocs/extensions/callbacks.md8+-
Minternal/daemon/callbacks.go6+-
Minternal/daemon/callbacks_delivery.go43++++-
Minternal/daemon/callbacks_test.go289++++++++++++++++++++++++++++++-
M docs/agents.md +1 -1
M docs/extensions/callbacks.md +3 -5
@@ -30,7 +30,7 @@ Members and agents manage their own registrations. Owner/moderator roles may lis
3030 3131 A supplied nonempty secret must be 16 to 128 bytes of ASCII `!` through `~`: embedded spaces are rejected despite the error's “printable ASCII” wording. An omitted/blank secret generates 32 random bytes encoded as 64 lowercase hex characters. Registration IDs are 16 random bytes encoded as 32 lowercase hex characters. Treat an ID as an opaque registration identifier, not authentication.3232 33-Registration returns a summary plus `secret` once. Summary fields are `id`, `owner`, `url`, `host`, `filter`, `created`, `lastDelivery`, `lastStatus`, `failures` and `paused`. Times are Unix seconds. `host` includes an explicit port. List responses omit `url` for registrations owned by someone other than the caller, and never include secrets. Pause/resume return a summary; removal returns `{"id":"...","removed":true}`. **Audit gap:** pause/resume currently return `record.summary()` without the listing's cross-owner URL redaction. Do not interpret the redacted list as a guarantee that an operator cannot retrieve a path/query through a mutation response.33+Registration returns a summary plus `secret` once. Summary fields are `id`, `owner`, `url`, `host`, `filter`, `created`, `lastDelivery`, `lastStatus`, `failures` and `paused`. Times are Unix seconds. `host` includes an explicit port. List, pause and resume responses omit `url` for registrations owned by someone other than the caller and never include secrets. Removal returns `{"id":"...","removed":true}`.3434 3535 ## Filter contract3636
@@ -69,14 +69,14 @@ The receiver acknowledges with any 2xx status within 10 seconds. Response conten
6969 7070 - Stored event acceptance captures eligible registrations for later work. Optional local planning failure does not reject a valid source event. Recovery is bounded by a 15-minute age limit and three background worker planning attempts, separately from delivery retries. Successfully planned child work retains its identity. Failed discovery recovery excludes registrations created at or after the acceptance second, so uncertain same-second registrations can be skipped.7171 - Repository-state callbacks are planned after object promotion, rather than waking a receiver while objects are missing. Ephemeral events use a separate immediate planning path whose intent carries the event; they do not gain durable source history or the stored-event planning recovery guarantee.72-- Before each POST the service rereads the registration, skips missing/paused registrations, checks current membership and applies `Gate.CanSee` to the queued event. A key with no remaining membership has its callback paused. Gate checks include bans, held events, host read policy, rooms and recipients.72+- Before each POST the service rereads the registration and skips missing or paused callbacks. It checks current membership and, for agents, requires an active grant covering the callback's room and repository filters. Losing membership or that grant coverage pauses the callback. Stored events are reloaded and must still exist, be unexpired and pass current read checks, including bans, held events, host read policy, rooms and recipients. Ephemeral events use their signed kind to identify the queued payload as their source.7373 - Each callback has at most one in-process POST at a time. A failed event gets two further scheduled attempts, after one minute and then five minutes. The third is final. Each failed HTTP attempt increments consecutive failures; success resets them. At 20 failures the callback pauses. Resumption does not promise replay of skipped or exhausted events.7474 7575 Outbound URLs use the shared [`NewPinnedClient`](../../internal/replication/callback.go), which dials resolved public addresses directly and refuses redirects. The lexical registration check rejects private/loopback IPs and local-style hostnames. A relay configured with a private/loopback public URL deliberately relaxes private-target restrictions for local use; HTTPS and redirect refusal remain. This is a deployment trust switch, not an exemption clients can request. DNS filtering is the implementation's explicit address-deny list, not a claim of a complete SSRF audit.7676 7777 Callbacks intentionally disclose the entire readable signed event to an external service. Secrets and URL tokens require protection in storage and at the receiver. Revocation cannot recall an already sent request.7878 79-**Read-state limits:** Delivery does not rerun `checkCallbackScope` or `AgentGrant.Active`; `Gate.CanSee` is a read gate, not a write-grant check. Paused/expired agents can retain membership/read access, so pausing a grant alone is not a guaranteed webhook kill switch. Pause the callback to stop it. Delivery checks the queued event payload, not a fresh source lookup; deletion/expiration alone is not a guaranteed cancellation of already planned deliveries. Do not broaden the guide's read-recheck wording into guarantees the implementation does not enforce.79+Restoring a grant does not automatically resume a paused callback. Resume it after its membership and grant scope are valid. Delivery checks apply to each attempt; an already dispatched request cannot be recalled.8080 8181 ## Errors, discovery and evidence8282
@@ -89,5 +89,3 @@ Evidence:
8989 - [`callbacks_test.go`](../../internal/daemon/callbacks_test.go): `TestCallbackRegistrationValidatesURLFilterScopeAndCap`, `TestCallbackManagementPermissionMatrix`, `TestCallbackMatchesFilterAndKeepsPrivateEventsBehindTheGate`, `TestCallbackDeliverySignsBodyRetriesAndPauses`, `TestCallbackDeliveryRechecksTheGateAndMembership`, `TestMCPCallbackTools`.9090 - [`callback_followups_test.go`](../../internal/daemon/callback_followups_test.go): planning failure, preserved completed work, paused/removed/later registrations, exhaustion and independent view failure.9191 - [`replication/callback.go`](../../internal/replication/callback.go) supplies the shared pinned HTTP client; this audit found no direct pinned-HTTP-client test in the replication test files. The callback restart test in [`callback_restart_acceptance_test.go`](../../internal/daemon/callback_restart_acceptance_test.go) exercises **replication** callback work, not proof of this HMAC webhook's end-to-end restart delivery.92-93-The URL-redaction, grant-state and queued-source limits above are unresolved implementation boundaries. This specification neither fixes them nor claims tests for stronger guarantees.
M internal/daemon/callbacks.go +5 -1
M internal/daemon/callbacks_delivery.go +39 -4
@@ -27,8 +27,8 @@ import (
2727 "github.com/FelineStateMachine/tinyrelay/internal/work"2828 )2929 30-// callbackPayload is the work intent body: the event itself, so delivery31-// does not depend on the event still being stored, and the attempt number.30+// callbackPayload carries an event and its delivery attempt. Stored events31+// are reloaded before delivery; ephemeral events use this signed payload.3232 type callbackPayload struct {3333 Event event.Event `json:"event"`3434 Attempt int `json:"attempt"`
@@ -84,6 +84,10 @@ func (s *callbackService) handleCallbackDelivery(ctx context.Context, intent wor
8484 if payload.Attempt < 1 {8585 payload.Attempt = 18686 }87+ if err := event.Validate(payload.Event); err != nil {88+ outcome = "invalid"89+ return errors.New("callback-delivery: invalid event")90+ }8791 lock := s.callbackLock(intent.Target)8892 lock.Lock()8993 defer lock.Unlock()
@@ -99,6 +103,23 @@ func (s *callbackService) handleCallbackDelivery(ctx context.Context, intent wor
99103 outcome = "paused"100104 return nil101105 }106+ source := payload.Event107+ if !event.IsEphemeral(payload.Event.Kind) {108+ var raw string109+ err := s.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND (expires=0 OR expires>?)`, payload.Event.ID, time.Now().Unix()).Scan(&raw)110+ if errors.Is(err, sql.ErrNoRows) {111+ outcome = "unauthorized"112+ return nil113+ }114+ if err != nil {115+ return err116+ }117+ parsed, err := event.Parse([]byte(raw))118+ if err != nil {119+ return err120+ }121+ source = parsed122+ }102123 role, err := s.community.Role(ctx, record.Owner)103124 if err != nil {104125 return err
@@ -109,11 +130,25 @@ func (s *callbackService) handleCallbackDelivery(ctx context.Context, intent wor
109130 outcome = "unauthorized"110131 return s.pauseCallback(ctx, record.ID, record.Failures, "paused: the key is no longer a member")111132 }112- if !s.gate.CanSee(ctx, payload.Event, relay.Session{PubKeys: []string{record.Owner}, RelayURL: s.relayURL}, nil) {133+ if role == "agent" {134+ grant, ok, err := s.community.AgentGrant(ctx, record.Owner)135+ if err != nil {136+ return err137+ }138+ if !ok || !grant.Active(time.Now().Unix()) {139+ outcome = "unauthorized"140+ return s.pauseCallback(ctx, record.ID, record.Failures, "paused: the agent grant is no longer active")141+ }142+ if err := s.checkCallbackScope(ctx, record.Owner, record.Filter); err != nil {143+ outcome = "unauthorized"144+ return s.pauseCallback(ctx, record.ID, record.Failures, "paused: the agent grant no longer covers this callback")145+ }146+ }147+ if !s.gate.CanSee(ctx, source, relay.Session{PubKeys: []string{record.Owner}, RelayURL: s.relayURL}, nil) {113148 outcome = "unauthorized"114149 return nil115150 }116- status, postErr := s.postCallback(ctx, record, payload.Event)151+ status, postErr := s.postCallback(ctx, record, source)117152 now := time.Now().Unix()118153 if postErr == nil {119154 outcome = "ok"
M internal/daemon/callbacks_test.go +287 -2
@@ -246,12 +246,20 @@ func TestCallbackManagementPermissionMatrix(t *testing.T) {
246246 if _, present := byOwner[member]["url"]; present {247247 t.Fatalf("operator listing carries another key's URL path: %v", byOwner[member])248248 }249- if _, err := tenant.Execute(ctx, actor, "pausecallback", []json.RawMessage{id(theirs)}); err != nil {249+ paused, err := tenant.Execute(ctx, actor, "pausecallback", []json.RawMessage{id(theirs)})250+ if err != nil {250251 t.Fatalf("pause by %s: %v", actor, err)251252 }252- if _, err := tenant.Execute(ctx, actor, "resumecallback", []json.RawMessage{id(theirs)}); err != nil {253+ if _, present := paused.(map[string]any)["url"]; present {254+ t.Fatalf("pause response carries another key's URL path: %v", paused)255+ }256+ resumed, err := tenant.Execute(ctx, actor, "resumecallback", []json.RawMessage{id(theirs)})257+ if err != nil {253258 t.Fatalf("resume by %s: %v", actor, err)254259 }260+ if _, present := resumed.(map[string]any)["url"]; present {261+ t.Fatalf("resume response carries another key's URL path: %v", resumed)262+ }255263 }256264 if _, err := tenant.Execute(ctx, owner, "pausecallback", []json.RawMessage{json.RawMessage(`"missing"`)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {257265 t.Fatalf("unknown id: %v", err)
@@ -594,6 +602,283 @@ func TestCallbackDeliveryRechecksTheGateAndMembership(t *testing.T) {
594602 }595603 }596604 605+func TestCallbackDeliveryStopsForInactiveAgentGrant(t *testing.T) {606+ _, tenant := loopbackTenant(t)607+ ctx := context.Background()608+ owner := tenant.Policy().Owner609+ agent, _ := event.PublicKey(testAgentSecret)610+ now := time.Now().Unix()611+ grant := agentGrantEvent(t, agent, now-1, now+3600, []string{"k", "1"})612+ if err := publishAs(t, tenant, grant); err != nil {613+ t.Fatal(err)614+ }615+ receiver := &callbackReceiver{status: http.StatusOK}616+ service := httptest.NewTLSServer(receiver)617+ defer service.Close()618+ tenant.callbacks.callbackClient = service.Client()619+ registered := addCallback(t, tenant, agent, service.URL+"/wake", `{"kinds":[1]}`)620+ id := registered["id"].(string)621+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "grant pause")622+ if err := publishAs(t, tenant, note); err != nil {623+ t.Fatal(err)624+ }625+ queued := pendingCallbackIntents(t, tenant, id, note.ID)626+ if len(queued) != 1 {627+ t.Fatalf("queued = %+v", queued)628+ }629+ if _, err := tenant.Execute(ctx, owner, "pauseagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {630+ t.Fatal(err)631+ }632+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil {633+ t.Fatal(err)634+ }635+ if receiver.count() != 0 {636+ t.Fatal("paused agent callback delivered")637+ }638+ var paused int639+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT paused FROM callbacks WHERE id=?", id).Scan(&paused); err != nil || paused != 1 {640+ t.Fatalf("inactive grant did not pause callback: paused=%d err=%v", paused, err)641+ }642+}643+644+func TestCallbackDeliverySkipsDeletedSource(t *testing.T) {645+ _, tenant := loopbackTenant(t)646+ ctx := context.Background()647+ member, _ := event.PublicKey(testMemberSecret)648+ setRole(t, tenant, member, "member")649+ receiver := &callbackReceiver{status: http.StatusOK}650+ service := httptest.NewTLSServer(receiver)651+ defer service.Close()652+ tenant.callbacks.callbackClient = service.Client()653+ registered := addCallback(t, tenant, member, service.URL+"/wake", `{"kinds":[1]}`)654+ id := registered["id"].(string)655+ note := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "deleted source")656+ if err := publishAs(t, tenant, note); err != nil {657+ t.Fatal(err)658+ }659+ queued := pendingCallbackIntents(t, tenant, id, note.ID)660+ if len(queued) != 1 {661+ t.Fatalf("queued = %+v", queued)662+ }663+ if _, err := tenant.store.DB().ExecContext(ctx, "DELETE FROM events WHERE id=?", note.ID); err != nil {664+ t.Fatal(err)665+ }666+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil {667+ t.Fatal(err)668+ }669+ if receiver.count() != 0 {670+ t.Fatal("deleted source callback delivered")671+ }672+}673+674+func TestCallbackDeliverySkipsExpiredSource(t *testing.T) {675+ _, tenant := loopbackTenant(t)676+ ctx := context.Background()677+ member, _ := event.PublicKey(testMemberSecret)678+ setRole(t, tenant, member, "member")679+ receiver := &callbackReceiver{status: http.StatusOK}680+ service := httptest.NewTLSServer(receiver)681+ defer service.Close()682+ tenant.callbacks.callbackClient = service.Client()683+ registered := addCallback(t, tenant, member, service.URL+"/wake", `{"kinds":[1]}`)684+ id := registered["id"].(string)685+ note := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "expired source")686+ if err := publishAs(t, tenant, note); err != nil {687+ t.Fatal(err)688+ }689+ queued := pendingCallbackIntents(t, tenant, id, note.ID)690+ if len(queued) != 1 {691+ t.Fatalf("queued = %+v", queued)692+ }693+ if _, err := tenant.store.DB().ExecContext(ctx, "UPDATE events SET expires=? WHERE id=?", time.Now().Unix()-1, note.ID); err != nil {694+ t.Fatal(err)695+ }696+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil {697+ t.Fatal(err)698+ }699+ if receiver.count() != 0 {700+ t.Fatal("expired source callback delivered")701+ }702+}703+704+func TestCallbackDeliveryRejectsForgedEphemeralProvenance(t *testing.T) {705+ _, tenant := loopbackTenant(t)706+ ctx := context.Background()707+ member, _ := event.PublicKey(testMemberSecret)708+ setRole(t, tenant, member, "member")709+ receiver := &callbackReceiver{status: http.StatusOK}710+ service := httptest.NewTLSServer(receiver)711+ defer service.Close()712+ tenant.callbacks.callbackClient = service.Client()713+ registered := addCallback(t, tenant, member, service.URL+"/wake", `{"kinds":[1]}`)714+ id := registered["id"].(string)715+ note := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "normal event")716+ if err := publishAs(t, tenant, note); err != nil {717+ t.Fatal(err)718+ }719+ queued := pendingCallbackIntents(t, tenant, id, note.ID)720+ if len(queued) != 1 {721+ t.Fatalf("queued = %+v", queued)722+ }723+ var payload map[string]json.RawMessage724+ if err := json.Unmarshal([]byte(queued[0].Payload), &payload); err != nil {725+ t.Fatal(err)726+ }727+ payload["ephemeral"] = json.RawMessage("true")728+ encoded, err := json.Marshal(payload)729+ if err != nil {730+ t.Fatal(err)731+ }732+ if _, err := tenant.store.DB().ExecContext(ctx, "UPDATE work_intents SET payload=? WHERE id=?", string(encoded), queued[0].ID); err != nil {733+ t.Fatal(err)734+ }735+ if _, err := tenant.store.DB().ExecContext(ctx, "DELETE FROM events WHERE id=?", note.ID); err != nil {736+ t.Fatal(err)737+ }738+ queued[0].Payload = string(encoded)739+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil || receiver.count() != 0 {740+ t.Fatalf("forged ephemeral provenance: err=%v deliveries=%d", err, receiver.count())741+ }742+}743+744+func TestCallbackDeliveryStopsWhenAgentScopeNarrows(t *testing.T) {745+ _, tenant := loopbackTenant(t)746+ ctx := context.Background()747+ agent, _ := event.PublicKey(testAgentSecret)748+ owner := tenant.Policy().Owner749+ now := time.Now().Unix()750+ grant := agentGrantEvent(t, agent, now-1, now+3600, []string{"k", "1"}, []string{"repo", owner + ":repo:read"})751+ if err := publishAs(t, tenant, grant); err != nil {752+ t.Fatal(err)753+ }754+ receiver := &callbackReceiver{status: http.StatusOK}755+ service := httptest.NewTLSServer(receiver)756+ defer service.Close()757+ tenant.callbacks.callbackClient = service.Client()758+ filter := `{"kinds":[1],"#a":["30617:` + owner + `:repo"]}`759+ registered := addCallback(t, tenant, agent, service.URL+"/wake", filter)760+ id := registered["id"].(string)761+ note := signedEvent(t, testOwnerSecret, 1, now, [][]string{{"a", "30617:" + owner + ":repo"}}, "narrowed scope")762+ if err := publishAs(t, tenant, note); err != nil {763+ t.Fatal(err)764+ }765+ queued := pendingCallbackIntents(t, tenant, id, note.ID)766+ if len(queued) != 1 {767+ t.Fatalf("queued = %+v", queued)768+ }769+ if _, err := tenant.store.DB().ExecContext(ctx, `UPDATE agent_grants SET scope=? WHERE agent=?`, `{"kinds":[1],"rooms":[],"repos":[],"rate":60}`, agent); err != nil {770+ t.Fatal(err)771+ }772+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil {773+ t.Fatal(err)774+ }775+ if receiver.count() != 0 {776+ t.Fatal("scope-narrowed agent callback delivered")777+ }778+ var paused int779+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT paused FROM callbacks WHERE id=?", id).Scan(&paused); err != nil || paused != 1 {780+ t.Fatalf("scope-narrowed callback not paused: paused=%d err=%v", paused, err)781+ }782+}783+784+func TestCallbackDeliveryAllowsActiveAgentWithStoredSource(t *testing.T) {785+ _, tenant := loopbackTenant(t)786+ ctx := context.Background()787+ agent, _ := event.PublicKey(testAgentSecret)788+ now := time.Now().Unix()789+ grant := agentGrantEvent(t, agent, now-1, now+3600, []string{"k", "1"})790+ if err := publishAs(t, tenant, grant); err != nil {791+ t.Fatal(err)792+ }793+ receiver := &callbackReceiver{status: http.StatusOK}794+ service := httptest.NewTLSServer(receiver)795+ defer service.Close()796+ tenant.callbacks.callbackClient = service.Client()797+ registered := addCallback(t, tenant, agent, service.URL+"/wake", `{"kinds":[1]}`)798+ id := registered["id"].(string)799+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "active grant")800+ if err := publishAs(t, tenant, note); err != nil {801+ t.Fatal(err)802+ }803+ queued := pendingCallbackIntents(t, tenant, id, note.ID)804+ if len(queued) != 1 {805+ t.Fatalf("queued = %+v", queued)806+ }807+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil {808+ t.Fatal(err)809+ }810+ if receiver.count() != 1 {811+ t.Fatalf("active agent callback deliveries=%d, want 1", receiver.count())812+ }813+}814+815+func TestEphemeralCallbackDeliveryUsesKindProvenance(t *testing.T) {816+ _, tenant := loopbackTenant(t)817+ ctx := context.Background()818+ member, _ := event.PublicKey(testMemberSecret)819+ setRole(t, tenant, member, "member")820+ receiver := &callbackReceiver{status: http.StatusOK}821+ service := httptest.NewTLSServer(receiver)822+ defer service.Close()823+ tenant.callbacks.callbackClient = service.Client()824+ registered := addCallback(t, tenant, member, service.URL+"/wake", `{"kinds":[20001]}`)825+ id := registered["id"].(string)826+ presence := signedEvent(t, testOwnerSecret, 20001, time.Now().Unix(), nil, "ephemeral")827+ tenant.notifyCallbacks(ctx, presence)828+ queued := pendingCallbackIntents(t, tenant, id, presence.ID)829+ if len(queued) != 1 {830+ t.Fatalf("queued = %+v", queued)831+ }832+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil {833+ t.Fatal(err)834+ }835+ if receiver.count() != 1 {836+ t.Fatalf("ephemeral callback count=%d, want 1", receiver.count())837+ }838+}839+840+func TestCallbackDeliveryStopsForRevokedOrExpiredAgentGrant(t *testing.T) {841+ for _, action := range []string{"revoked", "expired"} {842+ t.Run(action, func(t *testing.T) {843+ _, tenant := loopbackTenant(t)844+ ctx := context.Background()845+ agent, _ := event.PublicKey(testAgentSecret)846+ now := time.Now().Unix()847+ grant := agentGrantEvent(t, agent, now-1, now+3600, []string{"k", "1"})848+ if err := publishAs(t, tenant, grant); err != nil {849+ t.Fatal(err)850+ }851+ receiver := &callbackReceiver{status: http.StatusOK}852+ service := httptest.NewTLSServer(receiver)853+ defer service.Close()854+ tenant.callbacks.callbackClient = service.Client()855+ registered := addCallback(t, tenant, agent, service.URL+"/wake", `{"kinds":[1]}`)856+ id := registered["id"].(string)857+ note := signedEvent(t, testOwnerSecret, 1, now, nil, action)858+ if err := publishAs(t, tenant, note); err != nil {859+ t.Fatal(err)860+ }861+ queued := pendingCallbackIntents(t, tenant, id, note.ID)862+ if len(queued) != 1 {863+ t.Fatalf("queued = %+v", queued)864+ }865+ column, value := "revoked_at", now866+ if action == "expired" {867+ column, value = "expires_at", now-1868+ }869+ if _, err := tenant.store.DB().ExecContext(ctx, "UPDATE agent_grants SET "+column+"=? WHERE agent=?", value, agent); err != nil {870+ t.Fatal(err)871+ }872+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil {873+ t.Fatal(err)874+ }875+ if receiver.count() != 0 {876+ t.Fatalf("%s agent callback delivered", action)877+ }878+ })879+ }880+}881+597882 func TestMCPCallbackTools(t *testing.T) {598883 app, tenant := testTenant(t)599884 member, _ := event.PublicKey(testMemberSecret)