- commit
- f9f2a9a9c04086aec81e55d05ca43c317169f94b
- parent
- 662bd28e909419b64c6112ef9161f7fb6682059e
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 295 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 182 | +++++++++++++++++++ |
| A | internal/ | 242 | +++++++++++++++++++++++++ |
| M | internal/ | 5 | +- |
| M | internal/ | 22 | ++- |
| M | internal/ | 28 | ++- |
@@ -0,0 +1,295 @@
1+package daemon2+3+// Approvals: a request for a decision is an ordinary comment or room message4+// that carries a request tag and names the people asked with p tags. The5+// asked person answers with a signed reaction (+ approves, - denies) or a6+// reply. Nothing here introduces a kind; the asker learns the answer through7+// the events it already watches.8+9+import (10+ "context"11+ "strconv"12+ "strings"13+ "time"14+15+ "github.com/FelineStateMachine/tinyrelay/internal/event"16+ "github.com/FelineStateMachine/tinyrelay/internal/storage"17+)18+19+const (20+ kindComment = 111121+ kindChatMessage = 922+ kindThreadRoot = 1123+ // approvalScan bounds how many stored events one listing walks.24+ approvalScan = 100025+)26+27+// approvalTypes is the bounded vocabulary of the request tag.28+var approvalTypes = []string{"approve", "decide", "question"}29+var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot}30+31+// approvalItem is one request as the asked person sees it.32+type approvalItem struct {33+ ID string `json:"id"`34+ Kind int `json:"kind"`35+ Type string `json:"type"`36+ Asker string `json:"asker"`37+ Asked []string `json:"asked"`38+ Subject string `json:"subject,omitempty"`39+ Content string `json:"content"`40+ CreatedAt int64 `json:"created_at"`41+ Expires int64 `json:"expires,omitempty"`42+ State string `json:"state"`43+ Room string `json:"room,omitempty"`44+ About *approvalAbout `json:"about,omitempty"`45+ Answer *approvalAnswer `json:"answer,omitempty"`46+ Event event.Event `json:"event"`47+}48+49+// approvalAbout is what the request refers to: a repository coordinate, an50+// event, or both, with the page that opens it.51+type approvalAbout struct {52+ Coordinate string `json:"coordinate,omitempty"`53+ Event string `json:"event,omitempty"`54+ Kind string `json:"kind,omitempty"`55+ URL string `json:"url"`56+}57+58+// approvalAnswer is the answer that counts: the newest reaction from an asked59+// key, or the newest reply when nobody reacted.60+type approvalAnswer struct {61+ ID string `json:"id"`62+ Kind int `json:"kind"`63+ Author string `json:"author"`64+ Decision string `json:"decision"`65+ Content string `json:"content"`66+ CreatedAt int64 `json:"created_at"`67+}68+69+// approvalRequest reports whether the event asks anyone for a decision and70+// which type it carries.71+func approvalRequest(e event.Event) (string, bool) {72+ if !containsInt(approvalKinds, e.Kind) {73+ return "", false74+ }75+ kind := strings.ToLower(strings.TrimSpace(event.Tag(e, "request")))76+ if !containsString(approvalTypes, kind) || len(approvalAsked(e)) == 0 {77+ return "", false78+ }79+ return kind, true80+}81+82+// approvalAsked lists the keys asked: every p tag other than the asker.83+func approvalAsked(e event.Event) []string {84+ return pushRecipients(e)85+}86+87+func approvalItemFrom(e event.Event, kind string, base string) approvalItem {88+ item := approvalItem{ID: e.ID, Kind: e.Kind, Type: kind, Asker: e.PubKey, Asked: approvalAsked(e), Subject: strings.TrimSpace(event.Tag(e, "subject")), Content: e.Content, CreatedAt: e.CreatedAt, Expires: event.Expiration(e), State: "open", Room: event.Tag(e, "h"), Event: e}89+ item.About = approvalAboutFrom(e, base)90+ return item91+}92+93+// approvalAboutFrom resolves the request's a or e reference to a page. A94+// repository coordinate opens the repository, or its issue or pull request95+// when the root is one; any other event opens the event page.96+func approvalAboutFrom(e event.Event, base string) *approvalAbout {97+ coordinate := ""98+ for _, name := range []string{"a", "A"} {99+ if value := event.Tag(e, name); value != "" {100+ coordinate = value101+ break102+ }103+ }104+ referenced, kind := "", ""105+ for _, ref := range [][2]string{{"E", "K"}, {"e", "k"}} {106+ if value := event.Tag(e, ref[0]); len(value) == 64 {107+ referenced, kind = value, event.Tag(e, ref[1])108+ break109+ }110+ }111+ if coordinate == "" && referenced == "" {112+ return nil113+ }114+ about := &approvalAbout{Coordinate: coordinate, Event: referenced, Kind: kind}115+ if owner, identifier, ok := repoCoordinate(coordinate); ok {116+ about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=home"117+ if view := map[string]string{"1621": "issue", "1618": "pr"}[kind]; view != "" && referenced != "" {118+ about.URL = base + "/repo?owner=" + owner + "&repo=" + identifier + "&view=" + view + "&id=" + referenced119+ }120+ } else if referenced != "" {121+ about.URL = base + "/e/" + referenced122+ } else {123+ about.URL = base + "/a/" + coordinate124+ }125+ return about126+}127+128+// approvalDecision maps a reaction to the answer it records. An empty129+// reaction counts as approval, as NIP-25 reads it.130+func approvalDecision(content string) string {131+ switch strings.TrimSpace(content) {132+ case "+", "":133+ return "approved"134+ case "-":135+ return "denied"136+ }137+ return ""138+}139+140+// approvalAnswers finds the answers to the given requests in one query and141+// settles each request's state. The newest reaction from an asked key wins;142+// a reply from an asked key answers a request that has no reaction.143+func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now int64) (map[string][]event.Event, error) {144+ answers := make(map[string][]event.Event, len(items))145+ if len(items) == 0 {146+ return answers, nil147+ }148+ ids := make([]string, 0, len(items))149+ authors := []string{}150+ asked := make(map[string]map[string]bool, len(items))151+ for _, item := range items {152+ ids = append(ids, item.ID)153+ authors = append(authors, item.Asked...)154+ asked[item.ID] = map[string]bool{}155+ for _, key := range item.Asked {156+ asked[item.ID][key] = true157+ }158+ }159+ rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction, kindComment}, Authors: uniqueStrings(authors), Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: approvalScan})160+ if err != nil {161+ return nil, err162+ }163+ sortEventsNewestFirst(rows.Events)164+ for _, row := range rows.Events {165+ if _, isRequest := approvalRequest(row); isRequest {166+ continue167+ }168+ for _, id := range event.TagValues(row, "e") {169+ if asked[id] != nil && asked[id][row.PubKey] {170+ answers[id] = append(answers[id], row)171+ }172+ }173+ }174+ return answers, nil175+}176+177+// approvalSettle fills the answer and state of one request from its answers,178+// which arrive newest first.179+func approvalSettle(item *approvalItem, rows []event.Event, now int64) {180+ var reply *event.Event181+ for i := range rows {182+ row := rows[i]183+ if row.Kind == kindReaction {184+ if decision := approvalDecision(row.Content); decision != "" {185+ item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: decision, Content: row.Content, CreatedAt: row.CreatedAt}186+ item.State = "answered"187+ return188+ }189+ } else if reply == nil {190+ reply = &row191+ }192+ }193+ if reply != nil {194+ item.Answer = &approvalAnswer{ID: reply.ID, Kind: reply.Kind, Author: reply.PubKey, Decision: "replied", Content: reply.Content, CreatedAt: reply.CreatedAt}195+ item.State = "answered"196+ return197+ }198+ if item.Expires > 0 && item.Expires <= now {199+ item.State = "expired"200+ }201+}202+203+// approvalsFor lists every request addressed to the key, newest first,204+// settled against its answers. Expired requests are kept so the person sees205+// what lapsed until maintenance removes them.206+func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storage.EventCursor, limit int) ([]approvalItem, bool, error) {207+ now := time.Now().Unix()208+ base := strings.TrimRight(t.publicURL, "/")209+ page, err := t.store.Query(ctx, event.Filter{Kinds: approvalKinds, Tags: map[string][]string{"p": {pubkey}}}, storage.QueryOptions{Access: storage.Access{PubKeys: []string{pubkey}}, Limit: limit, Before: before})210+ if err != nil {211+ return nil, false, err212+ }213+ items := make([]approvalItem, 0, len(page.Events))214+ for _, row := range page.Events {215+ kind, ok := approvalRequest(row)216+ if !ok || row.PubKey == pubkey {217+ continue218+ }219+ items = append(items, approvalItemFrom(row, kind, base))220+ }221+ answers, err := t.approvalAnswers(ctx, items, now)222+ if err != nil {223+ return nil, false, err224+ }225+ for i := range items {226+ approvalSettle(&items[i], answers[items[i].ID], now)227+ }228+ return items, page.More, nil229+}230+231+// openApprovals lists the unanswered, unexpired requests addressed to the232+// key, which the badge and the panel count.233+func (t *Tenant) openApprovals(ctx context.Context, pubkey string) []approvalItem {234+ var open []approvalItem235+ var cursor *storage.EventCursor236+ for scanned := 0; scanned < approvalScan; scanned += 100 {237+ items, more, err := t.approvalsFor(ctx, pubkey, cursor, 100)238+ if err != nil {239+ return open240+ }241+ for _, item := range items {242+ if item.State == "open" {243+ open = append(open, item)244+ }245+ }246+ if !more || len(items) == 0 {247+ break248+ }249+ last := items[len(items)-1]250+ cursor = &storage.EventCursor{CreatedAt: last.CreatedAt, ID: last.ID}251+ }252+ return open253+}254+255+// approvalNotices wakes each asked person in the approvals category. The256+// body names the asker and the subject, and the payload carries the actions257+// the device can answer with: approve and deny for decisions, reply for258+// every type.259+func (t *Tenant) approvalNotices(e event.Event, kind string) []pushNotice {260+ base := strings.TrimRight(t.publicURL, "/")261+ subject := strings.TrimSpace(event.Tag(e, "subject"))262+ if subject == "" {263+ subject = e.Content264+ }265+ body := shortKey(e.PubKey) + " asks: " + excerpt(subject)266+ actions := []pushAction{{Action: "reply", Title: "Reply"}}267+ if kind != "question" {268+ actions = []pushAction{{Action: "approve", Title: "Approve"}, {Action: "deny", Title: "Deny"}, {Action: "reply", Title: "Reply"}}269+ }270+ var notices []pushNotice271+ for _, recipient := range approvalAsked(e) {272+ notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals?id=" + e.ID, actions: actions})273+ }274+ return notices275+}276+277+func shortKey(pubkey string) string {278+ if len(pubkey) > 12 {279+ return pubkey[:12]280+ }281+ return pubkey282+}283+284+func containsInt(values []int, wanted int) bool {285+ for _, value := range values {286+ if value == wanted {287+ return true288+ }289+ }290+ return false291+}292+293+func approvalCursor(item approvalItem) string {294+ return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID295+}@@ -0,0 +1,182 @@
1+package daemon2+3+// Approval browsing: the requests addressed to the caller with their state4+// and counts, and one request with every answer it received. Both read5+// through the tenant gate with the caller's session, like the other browse6+// methods.7+8+import (9+ "context"10+ "encoding/json"11+ "errors"12+ "fmt"13+ "strings"14+ "time"15+16+ "github.com/FelineStateMachine/tinyrelay/internal/event"17+ "github.com/FelineStateMachine/tinyrelay/internal/storage"18+)19+20+type approvalBrowseRequest struct {21+ Limit int `json:"limit"`22+ Cursor string `json:"cursor"`23+ State string `json:"state"`24+ ID string `json:"id"`25+ Event string `json:"event"`26+}27+28+// approvalDevice is one of the caller's registered devices, as a fact for29+// the panel: when it was registered and which categories it wakes for.30+type approvalDevice struct {31+ CreatedAt int64 `json:"created_at"`32+ Categories []string `json:"categories"`33+}34+35+func approvalBrowseMethod(method string) bool {36+ return method == "browseapprovals" || method == "browseapproval"37+}38+39+func (t *Tenant) executeApprovals(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {40+ if actor == "" {41+ return nil, errors.New("auth-required: sign in to see requests addressed to you")42+ }43+ q := approvalBrowseRequest{}44+ if len(params) > 0 {45+ if err := json.Unmarshal(params[0], &q); err != nil {46+ return nil, fmt.Errorf("invalid: approval parameters: %w", err)47+ }48+ }49+ if q.Limit <= 0 || q.Limit > 100 {50+ q.Limit = 5051+ }52+ switch method {53+ case "browseapprovals":54+ return t.browseApprovals(ctx, actor, q)55+ case "browseapproval":56+ if q.ID == "" {57+ q.ID = q.Event58+ }59+ return t.browseApproval(ctx, actor, q.ID)60+ }61+ return nil, errors.New("unsupported: browse operation")62+}63+64+// browseApprovals lists the requests addressed to the caller. The counts65+// cover every request the walk saw, so the panel can say how many wait even66+// when the page shows only one state.67+func (t *Tenant) browseApprovals(ctx context.Context, actor string, q approvalBrowseRequest) (any, error) {68+ if q.State == "" {69+ q.State = "all"70+ }71+ if !containsString([]string{"open", "answered", "expired", "all"}, q.State) {72+ return nil, errors.New("invalid: approval state")73+ }74+ cursor, err := parseCollaborationCursor(q.Cursor)75+ if err != nil {76+ return nil, errors.New("invalid: approval cursor")77+ }78+ session := browseSession(t, actor)79+ items := make([]approvalItem, 0, q.Limit)80+ counts := map[string]int{"open": 0, "answered": 0, "expired": 0}81+ var oldest int6482+ next := ""83+ for scanned := 0; scanned < approvalScan; scanned += 100 {84+ page, more, err := t.approvalsFor(ctx, actor, cursor, 100)85+ if err != nil {86+ return nil, err87+ }88+ for _, item := range page {89+ cursor = &storage.EventCursor{CreatedAt: item.CreatedAt, ID: item.ID}90+ if !t.gate.CanSee(ctx, item.Event, session, nil) {91+ continue92+ }93+ counts[item.State]++94+ if item.State == "open" && (oldest == 0 || item.CreatedAt < oldest) {95+ oldest = item.CreatedAt96+ }97+ if q.State != "all" && q.State != item.State {98+ continue99+ }100+ if len(items) < q.Limit {101+ items = append(items, item)102+ } else if next == "" {103+ next = approvalCursor(items[len(items)-1])104+ }105+ }106+ if !more || len(page) == 0 {107+ break108+ }109+ if next == "" && len(items) == q.Limit {110+ next = approvalCursor(items[len(items)-1])111+ }112+ }113+ devices, err := t.approvalDevices(ctx, actor)114+ if err != nil {115+ return nil, err116+ }117+ return map[string]any{"items": items, "next_cursor": next, "counts": counts, "oldest_open": oldest, "devices": devices}, nil118+}119+120+// browseApproval returns one request with every reaction and reply from the121+// asked keys, newest first. The asked people and the asker may read it.122+func (t *Tenant) browseApproval(ctx context.Context, actor, id string) (any, error) {123+ if len(id) != 64 {124+ return nil, errors.New("invalid: approval id")125+ }126+ session := browseSession(t, actor)127+ rows, err := t.Query(ctx, []event.Filter{{IDs: []string{id}, Kinds: approvalKinds, Limit: intPtr(1)}}, session)128+ if err != nil {129+ return nil, err130+ }131+ if len(rows) != 1 {132+ return nil, errors.New("not found: request")133+ }134+ kind, ok := approvalRequest(rows[0])135+ if !ok {136+ return nil, errors.New("not found: request")137+ }138+ item := approvalItemFrom(rows[0], kind, strings.TrimRight(t.publicURL, "/"))139+ if item.Asker != actor && !containsString(item.Asked, actor) {140+ return nil, errors.New("restricted: this request is not addressed to you")141+ }142+ now := time.Now().Unix()143+ answers, err := t.approvalAnswers(ctx, []approvalItem{item}, now)144+ if err != nil {145+ return nil, err146+ }147+ approvalSettle(&item, answers[item.ID], now)148+ history := answers[item.ID]149+ if history == nil {150+ history = []event.Event{}151+ }152+ return map[string]any{"item": item, "answers": history}, nil153+}154+155+// approvalDevices lists the caller's registered devices with the categories156+// each one chose. Endpoints and keys stay out of the result.157+func (t *Tenant) approvalDevices(ctx context.Context, pubkey string) ([]approvalDevice, error) {158+ rows, err := t.store.DB().QueryContext(ctx, "SELECT created_at, categories FROM web_push WHERE pubkey=? ORDER BY created_at", pubkey)159+ if err != nil {160+ return nil, err161+ }162+ defer rows.Close()163+ devices := []approvalDevice{}164+ for rows.Next() {165+ var device approvalDevice166+ var raw string167+ if err := rows.Scan(&device.CreatedAt, &raw); err != nil {168+ return nil, err169+ }170+ device.Categories = pushCategories171+ if allowed := decodeCategories(raw); allowed != nil {172+ device.Categories = []string{}173+ for _, category := range pushCategories {174+ if allowed[category] {175+ device.Categories = append(device.Categories, category)176+ }177+ }178+ }179+ devices = append(devices, device)180+ }181+ return devices, rows.Err()182+}@@ -0,0 +1,242 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "strconv"7+ "strings"8+ "testing"9+ "time"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+)13+14+const (15+ approvalAskerSecret = "5555555555555555555555555555555555555555555555555555555555555555"16+ approvalOtherSecret = "6666666666666666666666666666666666666666666666666666666666666666"17+)18+19+func approvalCall(t *testing.T, tenant *Tenant, actor, method string, params map[string]any) (map[string]any, error) {20+ t.Helper()21+ raw, err := json.Marshal(params)22+ if err != nil {23+ t.Fatal(err)24+ }25+ value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw})26+ if err != nil {27+ return nil, err28+ }29+ encoded, err := json.Marshal(value)30+ if err != nil {31+ t.Fatal(err)32+ }33+ var result map[string]any34+ if err := json.Unmarshal(encoded, &result); err != nil {35+ t.Fatal(err)36+ }37+ return result, nil38+}39+40+func approvalByID(items []map[string]any, id string) map[string]any {41+ for _, item := range items {42+ if item["id"] == id {43+ return item44+ }45+ }46+ return nil47+}48+49+func TestBrowseApprovalsStatesAndAccess(t *testing.T) {50+ _, tenant := testTenant(t)51+ ctx := context.Background()52+ owner := tenant.Policy().Owner53+ asker, other := wikiKey(t, approvalAskerSecret), wikiKey(t, approvalOtherSecret)54+ now := time.Now().Unix()55+56+ open := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-400, "Publish release notes 1.4 as drafted?", []string{"request", "approve"}, []string{"p", owner}, []string{"subject", "Publish release notes 1.4"}, []string{"a", "30617:" + owner + ":notes"}, []string{"expiration", strconv.FormatInt(now+3600, 10)})57+ decided := wikiPublish(t, tenant, approvalAskerSecret, kindThreadRoot, now-300, "Mark pull request 23 merged?", []string{"request", "decide"}, []string{"p", owner}, []string{"h", tenant.meta.Name})58+ // An older denial, a newer approval and a stranger's reaction: the59+ // newest reaction from an asked key wins and the stranger is ignored.60+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-250, "-", []string{"e", decided.ID}, []string{"p", asker})61+ approval := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-200, "+", []string{"e", decided.ID}, []string{"p", asker})62+ wikiPublish(t, tenant, approvalOtherSecret, kindReaction, now-100, "-", []string{"e", decided.ID}, []string{"p", asker})63+ question := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-200, "Should the edge be mentioned?", []string{"request", "question"}, []string{"p", owner}, []string{"E", decided.ID, "", asker}, []string{"K", "11"}, []string{"P", asker})64+ reply := wikiPublish(t, tenant, wikiOwnerSecret, kindComment, now-150, "Leave it out.", []string{"E", question.ID, "", asker}, []string{"K", "1111"}, []string{"P", asker}, []string{"e", question.ID, "", asker}, []string{"k", "1111"}, []string{"p", asker})65+ // An expired request is saved directly, as it would have been before it66+ // lapsed, and shows as expired until maintenance removes it.67+ expired := event.Event{Kind: kindComment, CreatedAt: now - 7200, Content: "Rotate the key tonight?", Tags: [][]string{{"request", "approve"}, {"p", owner}, {"expiration", strconv.FormatInt(now-3600, 10)}}}68+ if err := event.Sign(&expired, approvalAskerSecret); err != nil {69+ t.Fatal(err)70+ }71+ if _, err := tenant.store.Save(ctx, expired, storageSave(tenant)); err != nil {72+ t.Fatal(err)73+ }74+ elsewhere := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-50, "Only for someone else", []string{"request", "approve"}, []string{"p", other})75+ // Ordinary comments and unknown request types are not requests.76+ wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-40, "Plain mention", []string{"p", owner})77+ wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-30, "Unknown type", []string{"request", "veto"}, []string{"p", owner})78+79+ result, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{})80+ if err != nil {81+ t.Fatal(err)82+ }83+ items := wikiItems(result, "items")84+ if len(items) != 4 || approvalByID(items, elsewhere.ID) != nil {85+ t.Fatalf("owner sees %d items: %v", len(items), items)86+ }87+ counts := result["counts"].(map[string]any)88+ if counts["open"] != 1.0 || counts["answered"] != 2.0 || counts["expired"] != 1.0 {89+ t.Fatalf("counts %v", counts)90+ }91+ if result["oldest_open"] != float64(open.CreatedAt) {92+ t.Fatalf("oldest open %v", result["oldest_open"])93+ }94+ item := approvalByID(items, open.ID)95+ if item["state"] != "open" || item["type"] != "approve" || item["subject"] != "Publish release notes 1.4" || item["asker"] != asker || item["expires"] != float64(now+3600) || item["answer"] != nil {96+ t.Fatalf("open item %v", item)97+ }98+ if about := item["about"].(map[string]any); about["coordinate"] != "30617:"+owner+":notes" || !strings.HasSuffix(about["url"].(string), "/repo?owner="+owner+"&repo=notes&view=home") {99+ t.Fatalf("about %v", about)100+ }101+ item = approvalByID(items, decided.ID)102+ answer, _ := item["answer"].(map[string]any)103+ if item["state"] != "answered" || item["room"] != tenant.meta.Name || answer == nil || answer["decision"] != "approved" || answer["id"] != approval.ID {104+ t.Fatalf("decided item %v", item)105+ }106+ item = approvalByID(items, question.ID)107+ answer, _ = item["answer"].(map[string]any)108+ if item["state"] != "answered" || answer == nil || answer["decision"] != "replied" || answer["id"] != reply.ID || answer["content"] != "Leave it out." {109+ t.Fatalf("question item %v", item)110+ }111+ if about := item["about"].(map[string]any); about["event"] != decided.ID || about["kind"] != "11" || !strings.HasSuffix(about["url"].(string), "/e/"+decided.ID) {112+ t.Fatalf("question about %v", about)113+ }114+ if item = approvalByID(items, expired.ID); item["state"] != "expired" {115+ t.Fatalf("expired item %v", item)116+ }117+118+ filtered, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"state": "open"})119+ if err != nil {120+ t.Fatal(err)121+ }122+ if items := wikiItems(filtered, "items"); len(items) != 1 || items[0]["id"] != open.ID {123+ t.Fatalf("open filter %v", items)124+ }125+ if _, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"state": "bogus"}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {126+ t.Fatalf("bogus state %v", err)127+ }128+ paged, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"limit": 2})129+ if err != nil {130+ t.Fatal(err)131+ }132+ if items := wikiItems(paged, "items"); len(items) != 2 || paged["next_cursor"] == "" {133+ t.Fatalf("paged %v", paged)134+ }135+ rest, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"limit": 2, "cursor": paged["next_cursor"]})136+ if err != nil {137+ t.Fatal(err)138+ }139+ if items := wikiItems(rest, "items"); len(items) != 2 || items[0]["id"] == wikiItems(paged, "items")[0]["id"] {140+ t.Fatalf("second page %v", rest)141+ }142+143+ // The other key sees only its own request; guests see nothing.144+ theirs, err := approvalCall(t, tenant, other, "browseapprovals", map[string]any{})145+ if err != nil {146+ t.Fatal(err)147+ }148+ if items := wikiItems(theirs, "items"); len(items) != 1 || items[0]["id"] != elsewhere.ID {149+ t.Fatalf("other sees %v", items)150+ }151+ if _, err := approvalCall(t, tenant, "", "browseapprovals", map[string]any{}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") {152+ t.Fatalf("guest %v", err)153+ }154+155+ // One request with its history: the asked person and the asker may156+ // read it, nobody else.157+ detail, err := approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": decided.ID})158+ if err != nil {159+ t.Fatal(err)160+ }161+ if got := detail["item"].(map[string]any); got["state"] != "answered" || got["answer"].(map[string]any)["decision"] != "approved" {162+ t.Fatalf("detail %v", detail)163+ }164+ if answers := detail["answers"].([]any); len(answers) != 2 || answers[0].(map[string]any)["id"] != approval.ID {165+ t.Fatalf("answers %v", answers)166+ }167+ if _, err := approvalCall(t, tenant, asker, "browseapproval", map[string]any{"id": decided.ID}); err != nil {168+ t.Fatalf("asker detail %v", err)169+ }170+ if _, err := approvalCall(t, tenant, other, "browseapproval", map[string]any{"id": decided.ID}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {171+ t.Fatalf("stranger detail %v", err)172+ }173+ if _, err := approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": reply.ID}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {174+ t.Fatalf("reply as request %v", err)175+ }176+}177+178+func TestApprovalRequestsWakeDevicesWithActionsAndCountOnBadge(t *testing.T) {179+ _, tenant := testTenant(t)180+ ctx := context.Background()181+ owner := tenant.Policy().Owner182+ asker := wikiKey(t, approvalAskerSecret)183+ now := time.Now().Unix()184+185+ request := event.Event{Kind: kindComment, PubKey: asker, CreatedAt: now, Tags: [][]string{{"request", "decide"}, {"p", owner}, {"subject", "Publish release notes"}, {"expiration", strconv.FormatInt(now+3600, 10)}}, Content: "Long body"}186+ notices := tenant.pushNotices(ctx, request)187+ if len(notices) != 1 || notices[0].category != pushApprovals || notices[0].recipient != owner || notices[0].body != asker[:12]+" asks: Publish release notes" || !strings.HasSuffix(notices[0].url, "/approvals?id="+request.ID) {188+ t.Fatalf("decision notices %+v", notices)189+ }190+ if actions := notices[0].actions; len(actions) != 3 || actions[0].Action != "approve" || actions[1].Action != "deny" || actions[2].Action != "reply" || actions[0].Title != "Approve" {191+ t.Fatalf("decision actions %+v", notices[0].actions)192+ }193+ question := event.Event{Kind: kindChatMessage, PubKey: asker, CreatedAt: now, Tags: [][]string{{"request", "question"}, {"p", owner}, {"h", "build"}}, Content: "Mention the edge?"}194+ notices = tenant.pushNotices(ctx, question)195+ if len(notices) != 1 || notices[0].body != asker[:12]+" asks: Mention the edge?" || len(notices[0].actions) != 1 || notices[0].actions[0].Action != "reply" {196+ t.Fatalf("question notices %+v", notices)197+ }198+ // A comment that asks nobody keeps its mention notice.199+ plain := event.Event{Kind: kindComment, PubKey: asker, CreatedAt: now, Tags: [][]string{{"p", owner}}, Content: "Hello"}200+ if notices := tenant.pushNotices(ctx, plain); len(notices) != 1 || notices[0].category != pushMentions {201+ t.Fatalf("plain notices %+v", notices)202+ }203+ if !containsString(pushCategories, pushApprovals) {204+ t.Fatal("approvals is not a device category")205+ }206+207+ // The actions travel through the payload to the device message.208+ message := tenant.pushMessage(ctx, pushPayload{Recipient: owner, Kind: pushApprovals, Text: notices[0].body, URL: notices[0].url, Actions: notices[0].actions})209+ encoded, err := json.Marshal(message)210+ if err != nil {211+ t.Fatal(err)212+ }213+ if !strings.Contains(string(encoded), `"actions":[{"action":"reply","title":"Reply"}]`) || !strings.Contains(string(encoded), `"tag":"tiny-approvals"`) {214+ t.Fatalf("message %s", encoded)215+ }216+ if encoded, _ := json.Marshal(tenant.pushMessage(ctx, pushPayload{Recipient: owner, Kind: pushMentions, Text: "hi"})); strings.Contains(string(encoded), "actions") {217+ t.Fatalf("mention message carries actions: %s", encoded)218+ }219+220+ // The badge counts open requests once, whether they arrived before or221+ // after the last inbox visit, and drops them once answered.222+ if err := tenant.markInboxSeen(ctx, owner); err != nil {223+ t.Fatal(err)224+ }225+ seen := tenant.inboxSeen(ctx, owner)226+ older := wikiPublish(t, tenant, approvalAskerSecret, kindComment, seen-100, "Older request", []string{"request", "approve"}, []string{"p", owner})227+ if got := tenant.inboxUnread(ctx, owner); got != 1 {228+ t.Fatalf("unread with an older open request = %d", got)229+ }230+ newer := wikiPublish(t, tenant, approvalAskerSecret, kindComment, seen+100, "Newer request", []string{"request", "approve"}, []string{"p", owner})231+ wikiPublish(t, tenant, approvalAskerSecret, kindChatMessage, seen+100, "Room request", []string{"request", "question"}, []string{"p", owner}, []string{"h", tenant.meta.Name})232+ if got := tenant.inboxUnread(ctx, owner); got != 3 {233+ t.Fatalf("unread with three open requests = %d", got)234+ }235+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, seen+200, "+", []string{"e", older.ID}, []string{"p", asker})236+ wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, seen+200, "-", []string{"e", newer.ID}, []string{"p", asker})237+ // The newer request still counts as a conversation event since the238+ // visit; the older one is answered and gone from the badge.239+ if got := tenant.inboxUnread(ctx, owner); got != 2 {240+ t.Fatalf("unread after answering = %d", got)241+ }242+}@@ -34,7 +34,7 @@ type clientBrowseRequest struct {
3434 }3535 3636 func clientBrowseMethod(method string) bool {37- return wikiBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent"}, method)37+ return wikiBrowseMethod(method) || approvalBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent"}, method)3838 }3939 4040 func (t *Tenant) browseRead(ctx context.Context, actor string) error {@@ -61,6 +61,9 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
6161 if wikiBrowseMethod(method) {6262 return t.executeWiki(ctx, actor, method, params)6363 }64+ if approvalBrowseMethod(method) {65+ return t.executeApprovals(ctx, actor, method, params)66+ }6467 q := clientBrowseRequest{}6568 if len(params) > 0 {6669 if err := json.Unmarshal(params[0], &q); err != nil {@@ -41,15 +41,25 @@ type pushPayload struct {
4141 Subject string `json:"subject"`4242 Text string `json:"text"`4343 URL string `json:"url,omitempty"`44+ // Actions are the answers the device offers on the notification.45+ Actions []pushAction `json:"actions,omitempty"`46+}47+48+// pushAction is one button on a notification. The service worker opens the49+// notification's url with answer=<action> when it is pressed.50+type pushAction struct {51+ Action string `json:"action"`52+ Title string `json:"title"`4453 }4554 4655 // pushMessage is what the service worker receives after decryption.4756 type pushMessage struct {48- Title string `json:"title"`49- Body string `json:"body"`50- URL string `json:"url"`51- Tag string `json:"tag"`52- Badge int `json:"badge"`57+ Title string `json:"title"`58+ Body string `json:"body"`59+ URL string `json:"url"`60+ Tag string `json:"tag"`61+ Badge int `json:"badge"`62+ Actions []pushAction `json:"actions,omitempty"`5363 }5464 5565 // pushRegistration is the browser's subscription with its chosen categories.@@ -374,5 +384,5 @@ func (t *Tenant) pushMessage(ctx context.Context, payload pushPayload) pushMessa
374384 if url == "" {375385 url = strings.TrimRight(t.publicURL, "/") + "/inbox"376386 }377- return pushMessage{Title: title, Body: body, URL: url, Tag: "tiny-" + payload.Kind, Badge: t.inboxUnread(ctx, payload.Recipient)}387+ return pushMessage{Title: title, Body: body, URL: url, Tag: "tiny-" + payload.Kind, Badge: t.inboxUnread(ctx, payload.Recipient), Actions: payload.Actions}378388 }@@ -1,9 +1,10 @@
11 package daemon22 33 // Device notification triggers. Each stored event that addresses a member4-// with registered devices becomes a short summary in one of four categories,4+// with registered devices becomes a short summary in one of five categories,55 // which the person picks per device: private messages, replies to their own6-// posts and repositories, mentions, and relay notices.6+// posts and repositories, mentions, requests for a decision, and relay7+// notices.78 89 import (910 "context"@@ -20,15 +21,18 @@ const (
2021 pushMessages = "messages"2122 pushReplies = "replies"2223 pushMentions = "mentions"24+ pushApprovals = "approvals"2325 pushRelay = "relay"2426 pushCoalesce = 30 * time.Second2527 pushExcerptMax = 1202628 )2729 28-var pushCategories = []string{pushMessages, pushReplies, pushMentions, pushRelay}30+var pushCategories = []string{pushMessages, pushReplies, pushMentions, pushApprovals, pushRelay}2931 3032 type pushNotice struct {3133 recipient, category, body, url string34+ // actions are the answers a device can give from the notification.35+ actions []pushAction3236 }3337 3438 // notifyDevices runs after an event is stored. It never fails the publish:@@ -75,13 +79,18 @@ func (t *Tenant) pushCoalesced(recipient, category string) bool {
7579 }7680 7781 func (t *Tenant) enqueuePushNotice(ctx context.Context, notice pushNotice) error {78- return t.enqueuePushPayload(ctx, pushPayload{Recipient: notice.recipient, Kind: notice.category, Text: notice.body, URL: notice.url})82+ return t.enqueuePushPayload(ctx, pushPayload{Recipient: notice.recipient, Kind: notice.category, Text: notice.body, URL: notice.url, Actions: notice.actions})7983 }8084 8185 // pushNotices decides who an event should wake and why.8286 func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice {8387 base := strings.TrimRight(t.publicURL, "/")8488 var notices []pushNotice89+ // A request for a decision wakes the people asked in its own category90+ // and nowhere else, so one event yields one notification per device.91+ if kind, ok := approvalRequest(e); ok {92+ return t.approvalNotices(e, kind)93+ }8594 switch e.Kind {8695 case event.KIND_WRAP, 4:8796 for _, recipient := range pushRecipients(e) {@@ -216,7 +225,9 @@ func (t *Tenant) markInboxSeen(ctx context.Context, pubkey string) error {
216225 }217226 218227 // inboxUnread counts events addressed to the key since it last opened the219-// inbox. It feeds the app badge; the page clears it on the next visit.228+// inbox, plus every request that still waits for the key's decision. It229+// feeds the app badge; the page clears the conversation part on the next230+// visit, and an answer clears the request.220231 func (t *Tenant) inboxUnread(ctx context.Context, pubkey string) int {221232 since := t.inboxSeen(ctx, pubkey)222233 // Count conversation kinds only, so relay records addressed to the@@ -229,6 +240,13 @@ func (t *Tenant) inboxUnread(ctx context.Context, pubkey string) int {
229240 if err != nil {230241 return 0231242 }243+ // An open request counts once: those newer than the last visit are244+ // already in the conversation count.245+ for _, item := range t.openApprovals(ctx, pubkey) {246+ if item.CreatedAt < since || !containsInt(filter.Kinds, item.Kind) {247+ count++248+ }249+ }232250 if count > 99 {233251 return 99234252 }