// inbox.go is RFC 0011 §6 at the ops seam: an inbox a person can page
// and CLEAR (`inbox.list`, `inbox.mark`), the channel preferences that
// decide what reaches them (`me.prefs`, `me.prefs.set`), and the three
// calls a browser needs to receive a push with no tab open
// (`me.push.key`, `me.push.subscribe`, `me.push.unsubscribe`).
//
// The division of labour is RFC 0011's: notify owns notifications,
// quarry owns the attention stream and the forge-local switches nobody
// else can know about (a muted repository, a toast preference, which
// browsers a person turned push on in). This file merges the two into
// the one list a person reads, and splits a mark back out to both.
//
// Like every "me" op, these carry no Caps: the router resolves a
// capability set against the repository named in the path, and these
// name none. Auth Token is the real gate.
package api
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
"craftdesign.group/quarry/internal/ops"
)
// InboxMessage is one notify notification in this package's own terms
// — stated here rather than imported, for AttentionEvent's reason: the
// ops layer names what it needs and the composition root owns the
// transport.
type InboxMessage struct {
ID string
// Workflow is what sent it, and the only thing on a message that
// says WHY it arrived. `inbox.list` turns it into a reason.
Workflow string
Subject string
Body string
Link string
At string
Read bool
Seen bool
}
// PushDevice is one browser a person turned push on in. quarry holds no
// endpoint (see oltp.PushDevice) — this is the list a preferences page
// shows so somebody can turn one of them off.
type PushDevice struct {
EndpointHash string `json:"endpoint_hash"`
UA string `json:"ua,omitempty"`
At string `json:"at,omitempty"`
}
// InboxPlane is the notify companion as the person-addressed ops need
// it. nil is a forge with no notify: `inbox.count` falls back to the
// local attention tail, `inbox.list` shows only local events, and the
// push and preference ops answer that this forge has no such thing
// rather than pretending a setting took.
type InboxPlane interface {
// Unread counts the account's unread in-app notifications.
Unread(ctx context.Context, account string) (int, error)
// List is one page, newest first: the messages, and the cursor for
// the next page ("" when this was the last).
List(ctx context.Context, account, before string, limit int) ([]InboxMessage, string, error)
// Mark moves messages to "read" or "archived" — either the named
// ids or, with all, the account's whole inbox.
Mark(ctx context.Context, account string, ids []string, all bool, as string) error
// Preferences answers workflow -> channel -> enabled. An absent
// workflow or channel is ENABLED, which is notify's own rule.
Preferences(ctx context.Context, account string) (map[string]map[string]bool, error)
// SetPreferences writes the whole matrix.
SetPreferences(ctx context.Context, account string, prefs map[string]map[string]bool) error
// PushKey is the VAPID public key a browser subscribes with.
PushKey(ctx context.Context) (string, error)
// PushSubscribe stores one browser's subscription.
PushSubscribe(ctx context.Context, account, endpoint, p256dh, auth string) error
// PushUnsubscribe drops one endpoint's subscription.
PushUnsubscribe(ctx context.Context, account, endpoint string) error
}
// AccountPrefStore is the forge-local half of a person's preferences:
// the switches notify has no opinion about (toasts, desktop
// notifications) and the per-repository mute quarry implements by not
// triggering at all. nil means this forge keeps no preferences.
type AccountPrefStore interface {
AccountPref(account, key string) string
SetAccountPref(account, key, value string) error
}
// PushDeviceStore remembers which browsers an account enabled push in.
// nil means the preferences page lists no devices — the subscriptions
// still work, since they live in notify.
type PushDeviceStore interface {
AddPushDevice(account, endpoint, ua string) error
DeletePushDevice(account, endpoint string) error
PushDevices(account string) ([]PushDevice, error)
}
// prefMute is the account_pref key holding the muted repositories, one
// space-separated list rather than a row per repository: the whole set
// is read on every notify trigger, and one key lookup beats a scan.
const prefMute = "mute"
// prefToasts and prefDesktop are the two forge-local switches. Both
// default ON when unset, so the row exists only for somebody who turned
// something off — "off is the absence of a preference" is already how
// SetAccountPref stores an empty value.
const (
prefToasts = "toasts"
prefDesktop = "notify_desktop"
)
// prefFamilies maps RFC 0011 §6's four preference FAMILIES to the
// workflows that carry them. A family is what a person recognizes ("a
// reply") — the workflow name is quarry's own plumbing, and a
// preferences page must not make somebody learn it.
var prefFamilies = []struct{ family, workflow string }{
{"mention", "quarry-mention"},
{"reply", "quarry-forum-reply"},
{"landing", "quarry-landing"},
{"activity", "quarry-activity"},
}
// prefChannels maps quarry's channel names to notify's. Two spellings
// exist because "in_app" is notify's wire token and an HTML form field
// named with an underscore reads as somebody else's vocabulary leaking
// into ours.
var prefChannels = map[string]string{"inapp": "in_app", "push": "push", "email": "email"}
// prefChannelOrder is the matrix's column order — a map would render
// the page differently on every load.
var prefChannelOrder = []string{"inapp", "push", "email"}
// workflowReason turns a workflow name into the one-word reason an
// inbox row shows. Anything unmapped falls back to the name minus the
// "quarry-" prefix, so a workflow added later still reads sensibly
// instead of rendering blank.
var workflowReason = map[string]string{
"quarry-mention": "mention",
"quarry-forum-reply": "reply",
"quarry-landing": "landing",
"quarry-activity": "activity",
"quarry-error": "error",
"quarry-slow": "slow",
}
func reasonOf(workflow string) string {
if r, ok := workflowReason[workflow]; ok {
return r
}
return strings.TrimPrefix(workflow, "quarry-")
}
// localIDPrefix marks an inbox row that came from quarry's own
// attention stream rather than notify. `inbox.mark` splits its ids back
// apart on it, so one list and one mark form serve both sources without
// the page having to keep them in separate buckets.
const localIDPrefix = "local:"
func localID(seq uint64) string { return localIDPrefix + strconv.FormatUint(seq, 10) }
// splitInboxIDs separates notify message ids from local attention
// sequences. An id that claims to be local but is not a number is
// dropped rather than forwarded to notify: it is quarry's own spelling
// gone wrong, and notify would 404 on it.
func splitInboxIDs(ids []string) (notify []string, seqs []uint64) {
for _, id := range ids {
if rest, ok := strings.CutPrefix(id, localIDPrefix); ok {
if n, err := strconv.ParseUint(rest, 10, 64); err == nil {
seqs = append(seqs, n)
}
continue
}
if id != "" {
notify = append(notify, id)
}
}
return notify, seqs
}
// mutedRepos reads an account's muted set.
func (d Deps) mutedRepos(account string) map[string]bool {
if d.Prefs == nil || account == "" {
return nil
}
fields := strings.Fields(d.Prefs.AccountPref(account, prefMute))
if len(fields) == 0 {
return nil
}
out := make(map[string]bool, len(fields))
for _, f := range fields {
out[f] = true
}
return out
}
// muted reports whether account has silenced notify for repo. The mute
// is deliberately NOT applied to the attention stream: a person who
// muted a repository still finds its events in their own inbox when
// they go looking — what they turned off is being TOLD (RFC 0011 §6,
// "a per-repo mute that quarry implements by not triggering").
func (d Deps) muted(account, repo string) bool {
if account == "" || repo == "" {
return false
}
return d.mutedRepos(account)[repo]
}
// prefBool reads a forge-local switch that defaults ON.
func (d Deps) prefBool(account, key string) bool {
if d.Prefs == nil {
return true
}
v := d.Prefs.AccountPref(account, key)
return v == "" || (v != "off" && v != "false" && v != "0")
}
type inboxRow struct {
// ID addresses the row in `inbox.mark`: a notify message id, or
// "local:<seq>" for one of quarry's own attention events.
ID string `json:"id"`
// Source is "notify" or "local" — which half of RFC 0011 the row
// came from, so a page can style a row it can only mark locally.
Source string `json:"source"`
// Reason is why it arrived: mention, reply, landing, activity.
Reason string `json:"reason,omitempty"`
Repo string `json:"repo,omitempty"`
From string `json:"from,omitempty"`
Summary string `json:"summary,omitempty"`
Link string `json:"link,omitempty"`
At string `json:"at"`
Read bool `json:"read"`
// Seq is the attention sequence for a local row, 0 for a notify one.
Seq uint64 `json:"seq,omitempty"`
}
type inboxListIn struct {
// Before is notify's cursor from a previous page's `next`.
Before string `json:"before" validate:"max=128"`
// Limit bounds the page; 0 takes the op's default.
Limit int `json:"limit" validate:"min=0,max=200"`
}
type inboxListOut struct {
Rows []inboxRow `json:"rows"`
// Next is the cursor for the following page, "" at the end.
Next string `json:"next,omitempty"`
// Read is the local attention read pointer: every local row above
// it is unread, which is the same number the badge is measured from.
Read uint64 `json:"read"`
}
type inboxMarkIn struct {
// IDs are the rows to mark; repeat the key (or send a JSON array).
IDs []string `json:"ids" validate:"max=500"`
// All marks the whole inbox instead, ignoring ids.
All bool `json:"all"`
// As is the state to move to: read, or done (archived — off the
// list entirely).
As string `json:"as" validate:"oneof=read|done,max=8"`
}
type inboxMarkOut struct {
OK bool `json:"ok"`
// Seq is the local attention pointer after the mark.
Seq uint64 `json:"seq,omitempty"`
}
type mePrefsIn struct{}
type mePrefsOut struct {
// Families is family -> channel -> enabled, over RFC 0011 §6's
// {mention, reply, landing, activity} x {inapp, push, email}.
Families map[string]map[string]bool `json:"families"`
// Channels is the column order the matrix renders in.
Channels []string `json:"channels"`
// Toasts and Desktop are the two forge-local switches, both on
// unless somebody turned them off.
Toasts bool `json:"toasts"`
Desktop bool `json:"notify_desktop"`
// Muted names the repositories this account silenced.
Muted []string `json:"muted"`
// Devices are the browsers push was enabled in.
Devices []PushDevice `json:"devices"`
// Push reports whether push can be configured at all here — false
// on a forge with no notify companion, which is what lets a page
// say so rather than offer a control that cannot work.
Push bool `json:"push"`
}
type mePrefsSetIn struct {
// Each family carries the channels that stay ON. The page posts the
// WHOLE matrix (an unticked box is an absent value), so what arrives
// is the intended end state and not a delta.
Mention []string `json:"mention" validate:"max=8"`
Reply []string `json:"reply" validate:"max=8"`
Landing []string `json:"landing" validate:"max=8"`
Activity []string `json:"activity" validate:"max=8"`
// Toasts and Desktop are "on"/"off"; an absent value is off, since
// the page posts every switch it renders.
Toasts string `json:"toasts" validate:"max=8"`
Desktop string `json:"notify_desktop" validate:"max=8"`
// Mute replaces the muted set; repeat the key per repository.
Mute []string `json:"mute" validate:"max=200"`
}
type mePushKeyIn struct{}
type mePushKeyOut struct {
// PublicKey is notify's VAPID application-server key, base64url.
// Empty when no notify is configured — a page reads that as "push
// is not available here".
PublicKey string `json:"publicKey"`
}
// pushKeysIn is the browser's PushSubscription.keys, nested exactly as
// PushSubscription.toJSON() spells it so a page can post the
// subscription object verbatim without reshaping it first.
type pushKeysIn struct {
P256dh string `json:"p256dh" validate:"max=256"`
Auth string `json:"auth" validate:"max=256"`
}
type mePushIn struct {
Endpoint string `json:"endpoint" validate:"required,max=2048"`
Keys pushKeysIn `json:"keys"`
// UA labels the device on the preferences page. Optional: an
// unlabelled device is still one somebody can turn off.
UA string `json:"ua" validate:"max=256"`
}
type mePushOut struct {
OK bool `json:"ok"`
// Devices is the account's device list after the change, so the
// page that just subscribed can render it without a second call.
Devices []PushDevice `json:"devices"`
}
type mePushDeleteIn struct {
Endpoint string `json:"endpoint" validate:"required,max=2048"`
}
// mergeInbox builds one time-sorted page out of notify's messages and
// quarry's own attention events. Newest first, which is the order an
// inbox is read in.
//
// Both halves are shown because they are not the same thing: notify
// holds what was SENT (and survives a forge restart), the attention
// stream holds what quarry itself noticed, and a forge running no
// notify has only the second. A row present in both is not
// de-duplicated — they carry different ids and different read state,
// and guessing that two rows are one is how an inbox starts hiding mail.
func mergeInbox(msgs []InboxMessage, events []AttentionEvent, readSeq uint64, limit int) []inboxRow {
rows := make([]inboxRow, 0, len(msgs)+len(events))
for _, m := range msgs {
rows = append(rows, inboxRow{
ID: m.ID, Source: "notify", Reason: reasonOf(m.Workflow),
Summary: m.Subject, Link: m.Link, At: m.At, Read: m.Read,
})
}
for _, e := range events {
if e.Kind == AttentionGap {
continue
}
rows = append(rows, inboxRow{
ID: localID(e.Seq), Source: "local", Reason: e.Kind, Repo: e.Repo,
From: e.From, Summary: e.Summary, Link: e.Link, At: e.At,
Read: e.Seq <= readSeq, Seq: e.Seq,
})
}
sort.SliceStable(rows, func(i, j int) bool {
return inboxAfter(rows[i].At, rows[j].At)
})
if limit > 0 && len(rows) > limit {
rows = rows[:limit]
}
return rows
}
// inboxAfter reports whether a is newer than b. Parsed rather than
// string-compared because the two sources are two clocks with two
// formats: notify stamps its own CreatedAt and quarry stamps
// RFC3339Nano, and a lexical compare would sort "2026-08-16T00:00:00Z"
// against "2026-08-16T00:00:00.000000001Z" by string length. An
// unparseable stamp sorts last rather than crashing the page.
func inboxAfter(a, b string) bool {
ta, aok := parseInboxTime(a)
tb, bok := parseInboxTime(b)
if aok && bok {
return ta.After(tb)
}
if aok != bok {
return aok
}
return a > b
}
func parseInboxTime(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
// registerInbox wires RFC 0011 §6's ops. Registered unconditionally,
// for registerAttention's reason: docs/api.md must be the same document
// on every node, and a forge with no notify answers honestly (an empty
// page, no push key) rather than 404ing a documented surface.
func registerInbox(r *ops.Registry, d Deps) error {
regs := []func() error{
func() error { return registerInboxList(r, d) },
func() error { return registerInboxMark(r, d) },
func() error { return registerMePrefs(r, d) },
func() error { return registerMePush(r, d) },
}
for _, reg := range regs {
if err := reg(); err != nil {
return err
}
}
return nil
}
func registerInboxList(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[inboxListIn, inboxListOut]{
Name: "inbox.list",
Doc: "One page of everything addressed to the signed-in account, newest first, merging the " +
"notify inbox with quarry's own attention stream. Each row carries a `reason` (mention, " +
"reply, landing, activity) derived from the workflow that sent it, and an `id` that " +
"`inbox.mark` takes back — a notify message id, or `local:<seq>` for an attention event. " +
"`before` is the `next` cursor from a previous page; local events appear on the first page " +
"only, since the attention window is bounded and `inbox.recent` serves it whole.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/me/inbox",
Run: func(ctx context.Context, in inboxListIn) (inboxListOut, error) {
who := ops.User(ctx)
if who == "" {
return inboxListOut{}, nil
}
limit := in.Limit
if limit == 0 {
limit = 50
}
out := inboxListOut{}
if d.AttnRead != nil {
out.Read = d.AttnRead.AttentionReadSeq(who)
}
var msgs []InboxMessage
if d.Inbox != nil {
var err error
msgs, out.Next, err = d.Inbox.List(ctx, who, in.Before, limit)
if err != nil {
// An outage is not an empty inbox, for inbox.count's
// reason: a page that hides outages teaches people
// to stop trusting it.
return inboxListOut{}, err
}
}
var events []AttentionEvent
if d.Attention != nil && in.Before == "" {
var err error
events, err = d.Attention.RecentAttention(who, limit)
if err != nil {
return inboxListOut{}, err
}
}
out.Rows = mergeInbox(msgs, events, out.Read, limit)
return out, nil
},
})
}
func registerInboxMark(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[inboxMarkIn, inboxMarkOut]{
Name: "inbox.mark",
Doc: "Mark inbox rows read, or done (archived — off the list). Takes the ids `inbox.list` " +
"gave, or `all` for the whole inbox. Ids reach notify; `local:<seq>` ids advance the " +
"account's attention read pointer, the same forward-only pointer `attention.read` moves, " +
"so clearing the inbox clears the badge whichever half a row came from.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/me/inbox/mark",
Run: func(ctx context.Context, in inboxMarkIn) (inboxMarkOut, error) {
who := ops.User(ctx)
if who == "" {
return inboxMarkOut{}, fmt.Errorf("marking the inbox needs a signed-in account: %w", ops.ErrInvalid)
}
as := in.As
if as == "" {
as = "read"
}
if !in.All && len(in.IDs) == 0 {
return inboxMarkOut{}, fmt.Errorf("name some ids or ask for all: %w", ops.ErrInvalid)
}
ids, seqs := splitInboxIDs(in.IDs)
if d.Inbox != nil && (in.All || len(ids) > 0) {
// "done" is quarry's word; notify's state is archived.
state := "read"
if as == "done" {
state = "archived"
}
if err := d.Inbox.Mark(ctx, who, ids, in.All, state); err != nil {
return inboxMarkOut{}, err
}
}
out := inboxMarkOut{OK: true}
if d.AttnRead == nil {
return out, nil
}
// The local pointer moves to the highest sequence the mark
// named — or, on an all-mark, to the account's own tip. It
// only ever goes forward (MarkAttentionRead's rule), so a
// stale page cannot resurrect dismissed mail.
var to uint64
if in.All {
if d.Attention != nil {
tip, err := d.Attention.AttentionTip(who)
if err != nil {
return inboxMarkOut{}, err
}
to = tip
}
} else {
for _, s := range seqs {
if s > to {
to = s
}
}
}
if to > 0 {
if err := d.AttnRead.MarkAttentionRead(who, to); err != nil {
return inboxMarkOut{}, err
}
}
out.Seq = d.AttnRead.AttentionReadSeq(who)
return out, nil
},
})
}
// currentPrefs renders the account's matrix: notify's stored rows over
// the default (everything on).
func (d Deps) currentPrefs(ctx context.Context, who string) (mePrefsOut, error) {
out := mePrefsOut{
Families: map[string]map[string]bool{},
Channels: prefChannelOrder,
Toasts: d.prefBool(who, prefToasts),
Desktop: d.prefBool(who, prefDesktop),
Push: d.Inbox != nil,
}
var stored map[string]map[string]bool
if d.Inbox != nil {
var err error
stored, err = d.Inbox.Preferences(ctx, who)
if err != nil {
return mePrefsOut{}, err
}
}
for _, f := range prefFamilies {
row := map[string]bool{}
for _, ch := range prefChannelOrder {
// Absent means ENABLED, which is notify's own resolution
// rule (internal/preferences: a channel is on unless a
// level explicitly sets it false). Defaulting to off here
// would silence a forge that has simply never saved a
// preference.
on := true
if s, ok := stored[f.workflow]; ok {
if v, ok := s[prefChannels[ch]]; ok {
on = v
}
}
row[ch] = on
}
out.Families[f.family] = row
}
for _, repo := range strings.Fields(d.prefsRaw(who, prefMute)) {
out.Muted = append(out.Muted, repo)
}
sort.Strings(out.Muted)
if d.PushDevices != nil {
devs, err := d.PushDevices.PushDevices(who)
if err != nil {
return mePrefsOut{}, err
}
out.Devices = devs
}
return out, nil
}
func (d Deps) prefsRaw(account, key string) string {
if d.Prefs == nil {
return ""
}
return d.Prefs.AccountPref(account, key)
}
func registerMePrefs(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[mePrefsIn, mePrefsOut]{
Name: "me.prefs",
Doc: "The signed-in account's notification preferences: RFC 0011 §6's family x channel " +
"matrix ({mention, reply, landing, activity} x {inapp, push, email}) as notify holds it, " +
"plus the forge-local switches quarry owns — in-page toasts, desktop notifications, the " +
"repositories this account muted, and the browsers push was enabled in. A channel with no " +
"stored preference is ON, which is notify's own rule.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/me/prefs",
Run: func(ctx context.Context, in mePrefsIn) (mePrefsOut, error) {
who := ops.User(ctx)
if who == "" {
return mePrefsOut{}, nil
}
return d.currentPrefs(ctx, who)
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[mePrefsSetIn, mePrefsOut]{
Name: "me.prefs.set",
Doc: "Replace the signed-in account's preferences. Each family carries the channels that stay " +
"ON (repeat the key), so the WHOLE matrix is posted and an absent channel is off — a " +
"delta would leave a box somebody just unticked still enabled. `mute` replaces the muted " +
"set: a muted repository still reaches the account's own inbox, it just stops being sent.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/me/prefs",
Run: func(ctx context.Context, in mePrefsSetIn) (mePrefsOut, error) {
who := ops.User(ctx)
if who == "" {
return mePrefsOut{}, fmt.Errorf("preferences need a signed-in account: %w", ops.ErrInvalid)
}
wanted := map[string][]string{
"mention": in.Mention, "reply": in.Reply,
"landing": in.Landing, "activity": in.Activity,
}
if d.Inbox != nil {
prefs := map[string]map[string]bool{}
for _, f := range prefFamilies {
on := map[string]bool{}
for _, ch := range wanted[f.family] {
if notifyCh, ok := prefChannels[ch]; ok {
on[notifyCh] = true
}
}
row := map[string]bool{}
for _, ch := range prefChannelOrder {
row[prefChannels[ch]] = on[prefChannels[ch]]
}
prefs[f.workflow] = row
}
if err := d.Inbox.SetPreferences(ctx, who, prefs); err != nil {
return mePrefsOut{}, err
}
}
if d.Prefs != nil {
if err := d.Prefs.SetAccountPref(who, prefToasts, onOff(in.Toasts)); err != nil {
return mePrefsOut{}, err
}
if err := d.Prefs.SetAccountPref(who, prefDesktop, onOff(in.Desktop)); err != nil {
return mePrefsOut{}, err
}
if err := d.Prefs.SetAccountPref(who, prefMute, strings.Join(in.Mute, " ")); err != nil {
return mePrefsOut{}, err
}
}
return d.currentPrefs(ctx, who)
},
})
}
// onOff normalizes a switch to the two strings account_pref stores. An
// empty value is "off": the page posts every switch it renders, so an
// absent one is an unticked box, not an unmentioned setting. "off" is
// stored explicitly (rather than as the empty deletion) because the
// default is ON — a deleted row would read as "never set", which means
// on, and the switch would refuse to stay off.
func onOff(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "off", "false", "0", "no":
return "off"
default:
return "on"
}
}
func registerMePush(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[mePushKeyIn, mePushKeyOut]{
Name: "me.push.key",
Doc: "notify's VAPID application-server public key, base64url — what a browser passes to " +
"pushManager.subscribe. Empty when this forge has no notify companion, which a page reads " +
"as \"push is not available here\". quarry never holds the private half.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/me/push/key",
Run: func(ctx context.Context, in mePushKeyIn) (mePushKeyOut, error) {
if d.Inbox == nil {
return mePushKeyOut{}, nil
}
key, err := d.Inbox.PushKey(ctx)
if err != nil {
return mePushKeyOut{}, err
}
return mePushKeyOut{PublicKey: key}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[mePushIn, mePushOut]{
Name: "me.push.subscribe",
Doc: "Register a browser for push. The body is the browser's own PushSubscription JSON " +
"({endpoint, keys:{p256dh, auth}}), forwarded to notify as subscriber credentials for the " +
"webpush provider. quarry stores no endpoint and no key — only a hash, a user-agent label " +
"and a timestamp, so the preferences page can list the devices somebody may want to turn off.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/me/push",
Run: func(ctx context.Context, in mePushIn) (mePushOut, error) {
who := ops.User(ctx)
if who == "" {
return mePushOut{}, fmt.Errorf("a push subscription needs a signed-in account: %w", ops.ErrInvalid)
}
if d.Inbox == nil {
return mePushOut{}, fmt.Errorf("this forge has no notification companion: %w", ops.ErrUnavailable)
}
if err := d.Inbox.PushSubscribe(ctx, who, in.Endpoint, in.Keys.P256dh, in.Keys.Auth); err != nil {
return mePushOut{}, err
}
out := mePushOut{OK: true}
if d.PushDevices != nil {
if err := d.PushDevices.AddPushDevice(who, in.Endpoint, in.UA); err != nil {
return mePushOut{}, err
}
devs, err := d.PushDevices.PushDevices(who)
if err != nil {
return mePushOut{}, err
}
out.Devices = devs
}
return out, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[mePushDeleteIn, mePushOut]{
Name: "me.push.unsubscribe",
Doc: "Stop pushing to one browser: drops the endpoint's credential in notify and forgets the " +
"device row. An endpoint the push service already discarded is treated as done, since that " +
"is the end state asked for.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/me/push/delete",
Run: func(ctx context.Context, in mePushDeleteIn) (mePushOut, error) {
who := ops.User(ctx)
if who == "" {
return mePushOut{}, fmt.Errorf("removing a push subscription needs a signed-in account: %w", ops.ErrInvalid)
}
if d.Inbox != nil {
if err := d.Inbox.PushUnsubscribe(ctx, who, in.Endpoint); err != nil {
return mePushOut{}, err
}
}
out := mePushOut{OK: true}
if d.PushDevices != nil {
if err := d.PushDevices.DeletePushDevice(who, in.Endpoint); err != nil {
return mePushOut{}, err
}
devs, err := d.PushDevices.PushDevices(who)
if err != nil {
return mePushOut{}, err
}
out.Devices = devs
}
return out, nil
},
})
}