diff --git a/.config/mise/config.toml b/.config/mise/config.toml
index 9992e78..a41b513 100644
--- a/.config/mise/config.toml
+++ b/.config/mise/config.toml
@@ -10,3 +10,6 @@ auto = true
[tasks.dev]
run = "go run ./cmd/meshtender"
+
+[tasks.reset]
+run = "go run ./cmd/meshtender --reset"
diff --git a/cmd/meshtender/main.go b/cmd/meshtender/main.go
index 18e75ff..c315dbd 100644
--- a/cmd/meshtender/main.go
+++ b/cmd/meshtender/main.go
@@ -4,6 +4,7 @@ package main
import (
"context"
"errors"
+ "flag"
"log/slog"
"net/http"
"os"
@@ -27,6 +28,11 @@ func main() {
}
func run(logger *slog.Logger) error {
+ var reset bool
+ flag.BoolVar(&reset, "reset", false,
+ "truncate all data except users, passkeys, sessions, and the server identity, then exit")
+ flag.Parse()
+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -46,6 +52,14 @@ func run(logger *slog.Logger) error {
}
logger.Info("migrations applied")
+ if reset {
+ if err := st.Reset(ctx); err != nil {
+ return err
+ }
+ logger.Info("database reset — kept users, passkeys, sessions, and the server identity")
+ return nil
+ }
+
idSvc, err := identity.LoadOrCreate(ctx, st, cfg.MasterKey)
if err != nil {
return err
diff --git a/internal/core/command_features.go b/internal/core/command_features.go
index acdeee7..22e11bf 100644
--- a/internal/core/command_features.go
+++ b/internal/core/command_features.go
@@ -37,7 +37,5 @@ func orderFeatures(present []string) {
})
}
-// The feature×operation table builder (FeatureTableFor) lives in internal/web so
-// both the app host and the root host can build it for the read-only consent /
-// requested-access views; this file keeps only the feature ordering, still used
-// by groupByFeature for the catalog/editor groupings.
+// This file keeps the feature ordering used by groupByFeature for the catalog and
+// share/org command-selection groupings.
diff --git a/internal/core/contribute.go b/internal/core/contribute.go
deleted file mode 100644
index ee24db4..0000000
--- a/internal/core/contribute.go
+++ /dev/null
@@ -1,200 +0,0 @@
-package core
-
-import (
- "net/http"
-
- "github.com/go-chi/chi/v5"
-
- "github.com/jleight/meshtender/internal/store"
- "github.com/jleight/meshtender/internal/web"
-)
-
-// orgContext resolves the {id} repeater (owned) and {orgID} the user belongs to.
-func (s *Handlers) orgContext(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) {
- owner := s.Auth.CurrentUserID(r.Context())
- id, ok := s.repeaterID(r)
- orgID, oerr := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "orgID"))
- if !ok || oerr != nil {
- http.NotFound(w, r)
- return nil, 0, false
- }
- rep, err := s.Store.GetRepeaterOwned(r.Context(), owner, id)
- if err != nil {
- http.NotFound(w, r)
- return nil, 0, false
- }
- if _, isMember, err := s.Store.OrgRole(r.Context(), orgID, owner); err != nil || !isMember {
- http.NotFound(w, r) // can only contribute to orgs you belong to
- return nil, 0, false
- }
- return rep, orgID, true
-}
-
-// pageContribute shows the org's current permission envelope for the owner to
-// review before consenting (also used for re-consent).
-func (s *Handlers) pageContribute(w http.ResponseWriter, r *http.Request) {
- rep, orgID, ok := s.orgContext(w, r)
- if !ok {
- return
- }
- org, err := s.Store.GetOrg(r.Context(), orgID)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- versionID, version, err := s.Store.CurrentVersion(r.Context(), orgID)
- if err != nil {
- http.Error(w, "could not load policy", http.StatusInternalServerError)
- return
- }
- adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), versionID)
- if err != nil {
- http.Error(w, "could not load policy", http.StatusInternalServerError)
- return
- }
- catalog, err := s.Store.ListCommands(r.Context())
- if err != nil {
- http.Error(w, "could not load commands", http.StatusInternalServerError)
- return
- }
- members := idSet(memberIDs)
- data := map[string]any{
- "Repeater": rep,
- "Org": org,
- "Version": version,
- "MemberFeatures": web.FeatureTableFor(catalog, members),
- // Admins inherit every member command, so their table is member ∪ admin.
- "AdminFeatures": web.FeatureTableFor(catalog, union(idSet(adminIDs), members)),
- }
-
- // If already contributed and behind the current version, show what changed
- // since the owner last consented.
- cvID, contributed, _ := s.Store.ConsentedVersionID(r.Context(), orgID, rep.ID)
- if contributed && cvID != versionID {
- cAdmin, cMember, err1 := s.Store.VersionCommandIDs(r.Context(), cvID)
- consentedNum, err2 := s.Store.VersionNumber(r.Context(), cvID)
- if err1 == nil && err2 == nil {
- tmpl := map[int64]string{}
- for _, c := range catalog {
- tmpl[c.ID] = c.Template
- }
- consented := union(idSet(cAdmin), idSet(cMember))
- current := union(idSet(adminIDs), idSet(memberIDs))
- data["Reconsent"] = true
- data["ConsentedVersion"] = consentedNum
- data["Added"] = templatesFor(current, consented, tmpl) // newly granted
- data["Removed"] = templatesFor(consented, current, tmpl) // no longer granted
- if notes, err := s.Store.VersionNotesSince(r.Context(), orgID, consentedNum); err == nil {
- data["Notes"] = notes
- }
- }
- }
-
- s.Render(w, r, "contribute.html", data)
-}
-
-// pageConsented shows, read-only, the exact commands this repeater is currently
-// consented to grant the org — the detail behind "consented to vN" on the
-// sharing page. It renders the consented version, which may lag the org's
-// current one.
-func (s *Handlers) pageConsented(w http.ResponseWriter, r *http.Request) {
- rep, orgID, ok := s.orgContext(w, r)
- if !ok {
- return
- }
- org, err := s.Store.GetOrg(r.Context(), orgID)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- cvID, contributed, err := s.Store.ConsentedVersionID(r.Context(), orgID, rep.ID)
- if err != nil || !contributed {
- http.NotFound(w, r) // not contributed → nothing consented to view
- return
- }
- version, err := s.Store.VersionNumber(r.Context(), cvID)
- if err != nil {
- http.Error(w, "could not load policy", http.StatusInternalServerError)
- return
- }
- adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), cvID)
- if err != nil {
- http.Error(w, "could not load policy", http.StatusInternalServerError)
- return
- }
- catalog, err := s.Store.ListCommands(r.Context())
- if err != nil {
- http.Error(w, "could not load commands", http.StatusInternalServerError)
- return
- }
- members := idSet(memberIDs)
- data := map[string]any{
- "Repeater": rep,
- "Org": org,
- "Version": version,
- "MemberFeatures": web.FeatureTableFor(catalog, members),
- "AdminFeatures": web.FeatureTableFor(catalog, union(idSet(adminIDs), members)),
- }
- // Note (with a re-consent link) when the org has moved past this version.
- if _, current, err := s.Store.CurrentVersion(r.Context(), orgID); err == nil && current > version {
- data["CurrentVersion"] = current
- }
- s.Render(w, r, "consented.html", data)
-}
-
-// union returns the set union of two id sets.
-func union(a, b map[int64]bool) map[int64]bool {
- out := make(map[int64]bool, len(a)+len(b))
- for id := range a {
- out[id] = true
- }
- for id := range b {
- out[id] = true
- }
- return out
-}
-
-// templatesFor returns the command templates for ids in `in` but not in `notIn`.
-func templatesFor(in, notIn map[int64]bool, tmpl map[int64]string) []string {
- var out []string
- for id := range in {
- if !notIn[id] {
- if t := tmpl[id]; t != "" {
- out = append(out, t)
- }
- }
- }
- return out
-}
-
-// handleContribute pins the repeater to the org's current permission version.
-func (s *Handlers) handleContribute(w http.ResponseWriter, r *http.Request) {
- rep, orgID, ok := s.orgContext(w, r)
- if !ok {
- return
- }
- versionID, _, err := s.Store.CurrentVersion(r.Context(), orgID)
- if err != nil {
- http.Error(w, "could not load policy", http.StatusInternalServerError)
- return
- }
- owner := s.Auth.CurrentUserID(r.Context())
- if err := s.Store.ContributeRepeater(r.Context(), orgID, rep.ID, versionID, owner); err != nil {
- http.Error(w, "could not contribute", http.StatusInternalServerError)
- return
- }
- http.Redirect(w, r, "/repeaters/"+rep.PublicID+"/share", http.StatusSeeOther)
-}
-
-// handleWithdraw removes the repeater from the org.
-func (s *Handlers) handleWithdraw(w http.ResponseWriter, r *http.Request) {
- rep, orgID, ok := s.orgContext(w, r)
- if !ok {
- return
- }
- if err := s.Store.WithdrawRepeater(r.Context(), orgID, rep.ID); err != nil {
- http.Error(w, "could not withdraw", http.StatusInternalServerError)
- return
- }
- http.Redirect(w, r, "/repeaters/"+rep.PublicID+"/share", http.StatusSeeOther)
-}
diff --git a/internal/core/org_participation.go b/internal/core/org_participation.go
new file mode 100644
index 0000000..8d39822
--- /dev/null
+++ b/internal/core/org_participation.go
@@ -0,0 +1,165 @@
+package core
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/jleight/meshtender/internal/store"
+)
+
+// repeaterOrgContext resolves the {id} repeater (must be owned by the caller) and
+// the {orgID} org slug (the caller must belong to it). It's the gate for the
+// per-org participation toggle a repeater owner controls.
+func (s *Handlers) repeaterOrgContext(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) {
+ owner := s.Auth.CurrentUserID(r.Context())
+ id, ok := s.repeaterID(r)
+ orgID, oerr := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "orgID"))
+ if !ok || oerr != nil {
+ http.NotFound(w, r)
+ return nil, 0, false
+ }
+ rep, err := s.Store.GetRepeaterOwned(r.Context(), owner, id)
+ if err != nil {
+ http.NotFound(w, r)
+ return nil, 0, false
+ }
+ if _, isMember, err := s.Store.OrgRole(r.Context(), orgID, owner); err != nil || !isMember {
+ http.NotFound(w, r) // can only manage participation in orgs you belong to
+ return nil, 0, false
+ }
+ return rep, orgID, true
+}
+
+// handleSetRepeaterOrg opts a repeater into or out of an org (owner only). A
+// repeater participates in every org its owner belongs to by default; this writes
+// or clears the opt-out.
+func (s *Handlers) handleSetRepeaterOrg(w http.ResponseWriter, r *http.Request) {
+ rep, orgID, ok := s.repeaterOrgContext(w, r)
+ if !ok {
+ return
+ }
+ exclude := r.FormValue("action") == "exclude"
+ if err := s.Store.SetRepeaterOrgExcluded(r.Context(), orgID, rep.ID, exclude); err != nil {
+ http.Error(w, "could not update participation", http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, sharePath(rep.PublicID), http.StatusSeeOther)
+}
+
+// pageOrgCommands lets a member restrict, for one org, which of the commands that
+// org is permitted to run actually run on the member's repeaters. No restriction
+// (the default) means every command in the org's ceiling can run.
+func (s *Handlers) pageOrgCommands(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ id, ok := s.orgID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ org, err := s.Store.GetOrg(r.Context(), id)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ if _, isMember, err := s.Store.OrgRole(r.Context(), id, uid); err != nil || !isMember {
+ http.NotFound(w, r)
+ return
+ }
+ ceiling, err := s.orgCeilingCommands(r)
+ if err != nil {
+ http.Error(w, "could not load commands", http.StatusInternalServerError)
+ return
+ }
+ optIn, _ := s.Store.OrgOptInCommandIDs(r.Context(), id, uid)
+ restricted := len(optIn) > 0
+ // Permissive (no list) shows everything checked: all ceiling commands may run.
+ checked := make(map[int64]bool, len(ceiling))
+ if restricted {
+ for _, cid := range optIn {
+ checked[cid] = true
+ }
+ } else {
+ for _, c := range ceiling {
+ checked[c.ID] = true
+ }
+ }
+ s.Render(w, r, "org_commands.html", map[string]any{
+ "Org": org,
+ "Groups": groupCommands(ceiling, checked),
+ "Restricted": restricted,
+ })
+}
+
+// handleSaveOrgCommands saves the member's per-org opt-in list. If every ceiling
+// command is selected (or none of the modes restrict), the list is cleared back to
+// permissive so we don't persist a redundant full allowlist.
+func (s *Handlers) handleSaveOrgCommands(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ id, ok := s.orgID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if _, isMember, err := s.Store.OrgRole(r.Context(), id, uid); err != nil || !isMember {
+ http.NotFound(w, r)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ // "Remove restriction" clears the list regardless of checkboxes.
+ if r.FormValue("clear") != "" {
+ if err := s.Store.SetOrgOptIn(r.Context(), id, uid, nil); err != nil {
+ http.Error(w, "could not save", http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/orgs/"+orgParam(r)+"/my-commands", http.StatusSeeOther)
+ return
+ }
+ ceiling, err := s.orgCeilingCommands(r)
+ if err != nil {
+ http.Error(w, "could not load commands", http.StatusInternalServerError)
+ return
+ }
+ chosen := parseCommandIDs(r.Form["cmd"])
+ // Selecting the full ceiling is equivalent to permissive — store nothing.
+ if len(chosen) >= len(ceiling) {
+ chosen = nil
+ }
+ if err := s.Store.SetOrgOptIn(r.Context(), id, uid, chosen); err != nil {
+ http.Error(w, "could not save", http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/orgs/"+orgParam(r)+"/my-commands", http.StatusSeeOther)
+}
+
+// orgCeilingCommands returns the catalog commands an org is ever permitted to run
+// (member or admin tier) — the universe the opt-in editor restricts within.
+func (s *Handlers) orgCeilingCommands(r *http.Request) ([]*store.Command, error) {
+ catalog, err := s.Store.ListCommands(r.Context())
+ if err != nil {
+ return nil, err
+ }
+ var out []*store.Command
+ for _, c := range catalog {
+ if c.OrgMemberAllowed || c.OrgAdminAllowed {
+ out = append(out, c)
+ }
+ }
+ return out, nil
+}
+
+// parseCommandIDs parses a slice of form values into catalog command ids, skipping
+// any that aren't valid integers.
+func parseCommandIDs(values []string) []int64 {
+ var ids []int64
+ for _, v := range values {
+ if cid, err := strconv.ParseInt(v, 10, 64); err == nil {
+ ids = append(ids, cid)
+ }
+ }
+ return ids
+}
diff --git a/internal/core/org_permissions.go b/internal/core/org_permissions.go
deleted file mode 100644
index 28e6a2e..0000000
--- a/internal/core/org_permissions.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package core
-
-import (
- "net/http"
- "strconv"
-
- "github.com/jleight/meshtender/internal/store"
- "github.com/jleight/meshtender/internal/web"
-)
-
-// permGroup is a category of catalog commands for the org permission editor,
-// with per-tier checkbox state.
-type permGroup = categoryGroup[permChoice]
-
-type permChoice struct {
- ID int64
- Template string
- Args string
- Risky bool
- AdminChecked bool
- MemberChecked bool
-}
-
-func groupPermissions(catalog []*store.Command, admin, member map[int64]bool) []permGroup {
- return groupByFeature(catalog, func(c *store.Command) permChoice {
- return permChoice{
- ID: c.ID, Template: c.Template, Args: c.Args, Risky: c.Risky,
- AdminChecked: admin[c.ID], MemberChecked: member[c.ID],
- }
- })
-}
-
-func idSet(ids []int64) map[int64]bool {
- m := make(map[int64]bool, len(ids))
- for _, id := range ids {
- m[id] = true
- }
- return m
-}
-
-// pageOrgPermissions renders the org's requested-access policy read-only. Visible
-// to any signed-in user (so prospective members can see what they'd consent to);
-// admins get an Edit button. The root host serves the same page anonymously.
-func (s *Handlers) pageOrgPermissions(w http.ResponseWriter, r *http.Request) {
- uid := s.Auth.CurrentUserID(r.Context())
- id, ok := s.orgID(r)
- if !ok {
- http.NotFound(w, r)
- return
- }
- org, err := s.Store.GetOrg(r.Context(), id)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- role, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
- if err != nil {
- http.Error(w, "could not load org", http.StatusInternalServerError)
- return
- }
- pv, err := web.BuildPermissionsView(r.Context(), s.Store, id)
- if err != nil {
- http.Error(w, "could not load policy", http.StatusInternalServerError)
- return
- }
- s.Render(w, r, "org_permissions.html", map[string]any{
- "Org": org,
- "Nav": web.OrgNav(org.Slug, "permissions", isMember),
- "CanEdit": role == "admin",
- "Perms": pv,
- })
-}
-
-// pageOrgPermissionsEdit is the admin editor for the org's requested-access policy.
-func (s *Handlers) pageOrgPermissionsEdit(w http.ResponseWriter, r *http.Request) {
- id, ok := s.requireOrgAdmin(w, r)
- if !ok {
- return
- }
- org, err := s.Store.GetOrg(r.Context(), id)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- versionID, version, err := s.Store.CurrentVersion(r.Context(), id)
- if err != nil {
- http.Error(w, "could not load policy", http.StatusInternalServerError)
- return
- }
- adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), versionID)
- if err != nil {
- http.Error(w, "could not load policy", http.StatusInternalServerError)
- return
- }
- catalog, err := s.Store.ListCommands(r.Context())
- if err != nil {
- http.Error(w, "could not load commands", http.StatusInternalServerError)
- return
- }
- s.Render(w, r, "permissions_edit.html", map[string]any{
- "Org": org,
- "Nav": web.OrgNav(org.Slug, "permissions", true),
- "Version": version,
- "Groups": groupPermissions(catalog, idSet(adminIDs), idSet(memberIDs)),
- })
-}
-
-func (s *Handlers) handleSaveOrgPermissions(w http.ResponseWriter, r *http.Request) {
- id, ok := s.requireOrgAdmin(w, r)
- if !ok {
- return
- }
- if err := r.ParseForm(); err != nil {
- http.Error(w, "bad form", http.StatusBadRequest)
- return
- }
- parse := func(field string) []int64 {
- var ids []int64
- for _, v := range r.Form[field] {
- if cid, err := strconv.ParseInt(v, 10, 64); err == nil {
- ids = append(ids, cid)
- }
- }
- return ids
- }
- uid := s.Auth.CurrentUserID(r.Context())
- note := r.FormValue("note")
- if _, err := s.Store.PublishVersion(r.Context(), id, note, uid, parse("admin"), parse("member")); err != nil {
- http.Error(w, "could not publish", http.StatusInternalServerError)
- return
- }
- http.Redirect(w, r, "/orgs/"+orgParam(r)+"/permissions", http.StatusSeeOther)
-}
diff --git a/internal/core/shares.go b/internal/core/shares.go
index 5497168..0f30188 100644
--- a/internal/core/shares.go
+++ b/internal/core/shares.go
@@ -16,7 +16,6 @@ import (
// pageShare renders the sharing page for a repeater the user owns: the current
// share link (if any) and the list of people who have accepted.
func (s *Handlers) pageShare(w http.ResponseWriter, r *http.Request) {
- uid := s.Auth.CurrentUserID(r.Context())
rep, id, ok := s.requireRepeaterOwned(w, r)
if !ok {
return
@@ -31,36 +30,20 @@ func (s *Handlers) pageShare(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load links", http.StatusInternalServerError)
return
}
- // Organizations section: orgs this repeater is contributed to, plus orgs the
- // owner belongs to but hasn't contributed it to yet.
- contributed, err := s.Store.ListRepeaterOrgs(r.Context(), id)
+ // Organizations section: every org the owner belongs to, with whether this
+ // repeater participates (the default) or has been opted out.
+ orgs, err := s.Store.ListRepeaterOrgMemberships(r.Context(), id)
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
return
}
- memberships, err := s.Store.ListOrgsForUser(r.Context(), uid)
- if err != nil {
- http.Error(w, "could not load memberships", http.StatusInternalServerError)
- return
- }
- in := map[int64]bool{}
- for _, c := range contributed {
- in[c.OrgID] = true
- }
- var available []*store.Org
- for _, m := range memberships {
- if !in[m.Org.ID] {
- available = append(available, m.Org)
- }
- }
s.Render(w, r, "share.html", map[string]any{
- "Repeater": rep,
- "Shares": shares,
- "Invites": invites,
- "Contributed": contributed,
- "Available": available,
- "BaseURL": s.absoluteURL(r, ""),
- "Error": r.URL.Query().Get("error"),
+ "Repeater": rep,
+ "Shares": shares,
+ "Invites": invites,
+ "Orgs": orgs,
+ "BaseURL": s.absoluteURL(r, ""),
+ "Error": r.URL.Query().Get("error"),
})
}
diff --git a/internal/core/templates/admin_catalog.html b/internal/core/templates/admin_catalog.html
index 2a4b1fe..6dc00ab 100644
--- a/internal/core/templates/admin_catalog.html
+++ b/internal/core/templates/admin_catalog.html
@@ -11,10 +11,11 @@
- The firmware commands MeshTender can send. A repeater owner can run anything; these flags seed what
- others are offered — share = default for a new one-off share, member/admin
- = the org default tiers. Risky commands can lock the owner out or brick a node. Hover a
- command to see what it does.
+ The firmware commands MeshTender can send. A repeater owner can run anything. Share seeds
+ the default command set for a new one-off share. Member/admin are the
+ ceiling of what an organization may ever run on a contributed repeater, by tier. Risky
+ commands can lock the owner out or brick a node — leave these out of the org tiers. Hover a command to
+ see what it does.
- These are the commands {{.Org.Name}}'s admins and members can run on this repeater over the mesh — the
- policy you consented to (v{{.Version}}). Hover a command to see what it does.
-
- {{if .CurrentVersion}}
-
- {{.Org.Name}} has since published v{{.CurrentVersion}}. This repeater stays on v{{.Version}} until you
- review the changes and re-consent.
-
- By consenting, you allow {{.Org.Name}}'s admins and members to run these commands on this repeater
- over the mesh (policy v{{.Version}}). If the org later
- adds commands, this repeater stays on v{{.Version}} until you review and re-consent. You can
- withdraw anytime. Hover a command to see what it does.
-
-
- {{if .Reconsent}}
-
-
Changes since you consented (v{{.ConsentedVersion}} → v{{.Version}})
- {{if .Added}}
Newly granted: {{range .Added}}{{.}} {{end}}
{{end}}
- {{if .Removed}}
No longer granted: {{range .Removed}}{{.}} {{end}}
+ Restrict which of the commands {{.Org.Name}} is permitted to run actually run on
+ your repeaters shared with it. This applies to every repeater you share with this organization.
+ {{if .Restricted}}
+ You're currently allowing only the checked commands.
+ {{else}}
+ No restriction is set — every command the organization is permitted to run can run. Uncheck commands to
+ restrict them.
+ {{end}}
+
- Current version: v{{.Version}}. Choose which commands org admins
- and members may run on contributed repeaters. Saving publishes a new version;
- members' repeaters stay on their consented version until owners re-consent to the additions.
- Admins effectively also get everything members get. Risky commands can take over or brick a node.
-
- Let an organization's admins and members help operate this repeater. You'll be asked to review and
- consent to the list of commands that organization members and admins will be able to run on your
- repeater. You can withdraw anytime.
+ This repeater is now shared with the organizations you belong to, so their admins and members can run
+ the commands those organizations are permitted to run on it. Opt out of any of them here — you can
+ change this anytime, and limit which commands an org may run, from the sharing page.
- Contribute this repeater to an organization so its admins and members can run their permitted
- commands on it over the mesh — but only the specific commands you review and approve first. You can
- withdraw anytime.
+ This repeater is shared with every organization you belong to, so their admins and members can run
+ the commands those organizations are permitted to run on it. Opt it out of any organization here, or
+ limit which commands an organization may run from its Limit commands page.
Join an organization to contribute this repeater to it.
+
Join an organization to share this repeater with it.
{{end}}
diff --git a/internal/core/web.go b/internal/core/web.go
index 2be387d..8c1eefd 100644
--- a/internal/core/web.go
+++ b/internal/core/web.go
@@ -142,10 +142,7 @@ func (s *Handlers) appRouter() chi.Router {
r.Post("/repeaters/{id}/unshare", s.handleUnshare)
r.Get("/repeaters/{id}/share/{userID}/commands", s.pageShareCommands)
r.Post("/repeaters/{id}/share/{userID}/commands", s.handleSetShareCommands)
- r.Get("/repeaters/{id}/orgs/{orgID}/consented", s.pageConsented)
- r.Get("/repeaters/{id}/orgs/{orgID}/contribute", s.pageContribute)
- r.Post("/repeaters/{id}/orgs/{orgID}/contribute", s.handleContribute)
- r.Post("/repeaters/{id}/orgs/{orgID}/withdraw", s.handleWithdraw)
+ r.Post("/repeaters/{id}/orgs/{orgID}/participation", s.handleSetRepeaterOrg)
r.Post("/invite/{token}/accept", s.handleAcceptInvite)
r.Get("/orgs/new", s.pageNewOrg)
@@ -154,9 +151,8 @@ func (s *Handlers) appRouter() chi.Router {
r.Post("/orgs/{id}/join", s.handleJoinOrg)
r.Post("/orgs/{id}/leave", s.handleLeaveOrg)
r.Post("/orgs/{id}/members/{userID}", s.handleSetOrgMember)
- r.Get("/orgs/{id}/permissions", s.pageOrgPermissions)
- r.Get("/orgs/{id}/permissions/edit", s.pageOrgPermissionsEdit)
- r.Post("/orgs/{id}/permissions/edit", s.handleSaveOrgPermissions)
+ r.Get("/orgs/{id}/my-commands", s.pageOrgCommands)
+ r.Post("/orgs/{id}/my-commands", s.handleSaveOrgCommands)
r.Get("/orgs/{id}/config", s.pageOrgConfig)
r.Get("/orgs/{id}/config/edit", s.pageOrgConfigEdit)
r.Post("/orgs/{id}/config/edit", s.handleSaveOrgConfig)
@@ -199,11 +195,6 @@ func (s *Handlers) pageRepeaters(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load repeaters", http.StatusInternalServerError)
return
}
- reconsent, err := s.Store.OwnedRepeatersNeedingReconsent(r.Context(), uid)
- if err != nil {
- http.Error(w, "could not load org state", http.StatusInternalServerError)
- return
- }
shareCounts, err := s.Store.RepeaterSharingCounts(r.Context(), uid)
if err != nil {
http.Error(w, "could not load sharing", http.StatusInternalServerError)
@@ -213,7 +204,6 @@ func (s *Handlers) pageRepeaters(w http.ResponseWriter, r *http.Request) {
s.Render(w, r, "repeaters.html", map[string]any{
"Owned": owned,
"Shared": shared,
- "Reconsent": reconsent,
"ShareCounts": shareCounts,
"Error": r.URL.Query().Get("error"),
})
@@ -232,11 +222,6 @@ func (s *Handlers) pageDashboard(w http.ResponseWriter, r *http.Request) {
}
owned, shared := splitOwnedShared(repeaters)
- reconsent, err := s.Store.OwnedRepeatersNeedingReconsent(ctx, uid)
- if err != nil {
- http.Error(w, "could not load org state", http.StatusInternalServerError)
- return
- }
orgs, err := s.Store.ListOrgsForUser(ctx, uid)
if err != nil {
http.Error(w, "could not load organizations", http.StatusInternalServerError)
@@ -277,7 +262,6 @@ func (s *Handlers) pageDashboard(w http.ResponseWriter, r *http.Request) {
"Orgs": first(orgs, 5),
"Mapped": mapped,
"Recent": recent,
- "Reconsent": reconsent,
"ShareCounts": shareCounts,
"Error": r.URL.Query().Get("error"),
})
diff --git a/internal/marketing/marketing.go b/internal/marketing/marketing.go
index 62a175c..1ca5346 100644
--- a/internal/marketing/marketing.go
+++ b/internal/marketing/marketing.go
@@ -44,8 +44,7 @@ func (s *Handlers) Routes() chi.Router {
r.Get("/orgs", s.pageOrgs) // public organization directory
r.Get("/orgs/{id}", s.pageOrgPublic) // public org page
r.Get("/orgs/{id}/repeaters", s.pageOrgRepeaters) // public repeater list + map
- r.Get("/orgs/{id}/config", s.pageOrgConfig) // public recommended config
- r.Get("/orgs/{id}/permissions", s.pageOrgPermissions) // public requested access
+ r.Get("/orgs/{id}/config", s.pageOrgConfig) // public recommended config
return r
}
diff --git a/internal/marketing/orgs.go b/internal/marketing/orgs.go
index dc75c8c..0e4b5aa 100644
--- a/internal/marketing/orgs.go
+++ b/internal/marketing/orgs.go
@@ -119,32 +119,6 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) {
s.Render(w, r, "org_config.html", data)
}
-// pageOrgPermissions renders an org's requested-access policy read-only for
-// anonymous visitors (so prospective members can see it before joining).
-func (s *Handlers) pageOrgPermissions(w http.ResponseWriter, r *http.Request) {
- id, ok := s.orgID(r)
- if !ok {
- http.NotFound(w, r)
- return
- }
- org, err := s.Store.GetOrg(r.Context(), id)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- pv, err := web.BuildPermissionsView(r.Context(), s.Store, id)
- if err != nil {
- http.Error(w, "could not load policy", http.StatusInternalServerError)
- return
- }
- s.Render(w, r, "org_permissions.html", map[string]any{
- "Org": org,
- "Nav": web.OrgNav(org.Slug, "permissions", false),
- "CanEdit": false,
- "Perms": pv,
- })
-}
-
// pageOrgRepeaters renders an org's public repeaters (those opted into the public
// map) with a map, for anonymous visitors.
func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/marketing/templates/landing.html b/internal/marketing/templates/landing.html
index cc3872d..8163011 100644
--- a/internal/marketing/templates/landing.html
+++ b/internal/marketing/templates/landing.html
@@ -46,7 +46,7 @@
{{template "icon-share" ""}}
Share with precision
-
Share access to your repeaters with single-use share links and command-level permissions. Contribute your repeater to an Organization will full transparency into which commands they can run.
+
Share access to your repeaters with single-use share links and command-level permissions. Join an organization and your repeaters are shared with it automatically — opt any out, or limit which commands it can run, whenever you like.
diff --git a/internal/store/commands.go b/internal/store/commands.go
index 3ff3b25..05b161f 100644
--- a/internal/store/commands.go
+++ b/internal/store/commands.go
@@ -21,22 +21,26 @@ type Command struct {
Description string
// Feature is the grouping area (e.g. "Radio", "Region") and Operation is the
// read/write/delete/action bucket, both used by the review/catalog UIs.
- Feature string
- Operation string
- Risky bool
- InShareDefault bool
- InOrgMemberDefault bool
- InOrgAdminDefault bool
+ Feature string
+ Operation string
+ Risky bool
+ // InShareDefault seeds the command set offered for a new one-off share.
+ InShareDefault bool
+ // OrgMemberAllowed / OrgAdminAllowed are the site-admin-controlled ceiling of
+ // what an org member / admin may ever run on a contributed repeater. (No longer
+ // just a seed — these are the authoritative per-tier limits.)
+ OrgMemberAllowed bool
+ OrgAdminAllowed bool
}
const commandCols = `id, key, template, category, args, arity, description, feature, operation, risky,
- in_share_default, in_org_member_default, in_org_admin_default`
+ in_share_default, org_member_allowed, org_admin_allowed`
func scanCommand(row pgx.Row) (*Command, error) {
var c Command
err := row.Scan(&c.ID, &c.Key, &c.Template, &c.Category, &c.Args, &c.Arity, &c.Description,
&c.Feature, &c.Operation, &c.Risky,
- &c.InShareDefault, &c.InOrgMemberDefault, &c.InOrgAdminDefault)
+ &c.InShareDefault, &c.OrgMemberAllowed, &c.OrgAdminAllowed)
if err != nil {
return nil, err
}
@@ -66,7 +70,7 @@ func (s *Store) GetCommand(ctx context.Context, id int64) (*Command, error) {
func (s *Store) UpdateCommandFlags(ctx context.Context, id int64, risky, share, orgMember, orgAdmin bool) error {
_, err := s.pool.Exec(ctx, `
UPDATE command_catalog
- SET risky = $2, in_share_default = $3, in_org_member_default = $4, in_org_admin_default = $5
+ SET risky = $2, in_share_default = $3, org_member_allowed = $4, org_admin_allowed = $5
WHERE id = $1`, id, risky, share, orgMember, orgAdmin)
if err != nil {
return fmt.Errorf("update command flags: %w", err)
diff --git a/internal/store/migrations/0022_simplify_org_permissions.sql b/internal/store/migrations/0022_simplify_org_permissions.sql
new file mode 100644
index 0000000..4e4ce6e
--- /dev/null
+++ b/internal/store/migrations/0022_simplify_org_permissions.sql
@@ -0,0 +1,72 @@
+-- +goose Up
+-- Rework org command permissions away from per-org versioned policies + repeater
+-- contribution/consent toward a simpler model:
+-- (a) the site catalog flags are the hard per-tier ceiling of what an org may
+-- ever run (org_member_allowed / org_admin_allowed, renamed from the old
+-- "default" flags that only seeded versions),
+-- (b) a repeater participates in every org its owner belongs to unless the
+-- owner opts it out (org_repeater_excludes),
+-- (c) an owner may optionally restrict, per org, which of the ceiling commands
+-- that org may run on their repeaters (org_command_optin); no rows = the
+-- full ceiling applies.
+-- The versioned policy tables and the consent-pinned contribution table go away.
+DROP TABLE org_repeaters;
+DROP TABLE org_permission_commands;
+DROP TABLE org_permission_versions;
+
+ALTER TABLE command_catalog RENAME COLUMN in_org_member_default TO org_member_allowed;
+ALTER TABLE command_catalog RENAME COLUMN in_org_admin_default TO org_admin_allowed;
+
+-- Opt-out set: a repeater participates in org O iff its owner is a member of O
+-- AND there is no exclude row. Absence = included (the common case writes nothing).
+CREATE TABLE org_repeater_excludes (
+ org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ repeater_id BIGINT NOT NULL REFERENCES repeaters(id) ON DELETE CASCADE,
+ PRIMARY KEY (org_id, repeater_id)
+);
+CREATE INDEX org_repeater_excludes_repeater_id_idx ON org_repeater_excludes(repeater_id);
+
+-- Per-(owner, org) optional command allowlist. No rows for an (org, owner) pair
+-- = permissive (the site ceiling applies); ≥1 row = restricted to exactly those
+-- commands. (Note: this is the opposite default from share_commands, where no
+-- rows means deny-all.)
+CREATE TABLE org_command_optin (
+ org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ command_id BIGINT NOT NULL REFERENCES command_catalog(id) ON DELETE CASCADE,
+ PRIMARY KEY (org_id, owner_id, command_id)
+);
+
+-- +goose Down
+DROP TABLE org_command_optin;
+DROP TABLE org_repeater_excludes;
+
+ALTER TABLE command_catalog RENAME COLUMN org_admin_allowed TO in_org_admin_default;
+ALTER TABLE command_catalog RENAME COLUMN org_member_allowed TO in_org_member_default;
+
+CREATE TABLE org_permission_versions (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ version INT NOT NULL,
+ note TEXT NOT NULL DEFAULT '',
+ created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (org_id, version)
+);
+
+CREATE TABLE org_permission_commands (
+ version_id BIGINT NOT NULL REFERENCES org_permission_versions(id) ON DELETE CASCADE,
+ command_id BIGINT NOT NULL REFERENCES command_catalog(id) ON DELETE CASCADE,
+ tier TEXT NOT NULL CHECK (tier IN ('admin', 'member')),
+ PRIMARY KEY (version_id, command_id, tier)
+);
+
+CREATE TABLE org_repeaters (
+ org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ repeater_id BIGINT NOT NULL REFERENCES repeaters(id) ON DELETE CASCADE,
+ consented_version_id BIGINT NOT NULL REFERENCES org_permission_versions(id),
+ contributed_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
+ contributed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (org_id, repeater_id)
+);
+CREATE INDEX org_repeaters_repeater_id_idx ON org_repeaters(repeater_id);
diff --git a/internal/store/org_command_optin.go b/internal/store/org_command_optin.go
new file mode 100644
index 0000000..1218b2b
--- /dev/null
+++ b/internal/store/org_command_optin.go
@@ -0,0 +1,43 @@
+package store
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// An owner's optional per-org command allowlist. No rows for an (org, owner) pair
+// means permissive — the site ceiling (org_member_allowed / org_admin_allowed)
+// applies unchanged. One or more rows restrict that org to exactly the listed
+// commands on the owner's repeaters (still intersected with the ceiling and tier).
+
+// OrgOptInCommandIDs returns the command ids an owner has opted into for an org.
+// An empty result means no restriction (permissive).
+func (s *Store) OrgOptInCommandIDs(ctx context.Context, orgID, ownerID int64) ([]int64, error) {
+ rows, err := s.pool.Query(ctx,
+ `SELECT command_id FROM org_command_optin WHERE org_id = $1 AND owner_id = $2`, orgID, ownerID)
+ if err != nil {
+ return nil, fmt.Errorf("org opt-in commands: %w", err)
+ }
+ return collectRows(rows, scanID)
+}
+
+// SetOrgOptIn replaces an owner's opt-in command list for an org. Passing no ids
+// clears the restriction (reverts to permissive).
+func (s *Store) SetOrgOptIn(ctx context.Context, orgID, ownerID int64, commandIDs []int64) error {
+ return s.inTx(ctx, func(tx pgx.Tx) error {
+ if _, err := tx.Exec(ctx,
+ `DELETE FROM org_command_optin WHERE org_id = $1 AND owner_id = $2`, orgID, ownerID); err != nil {
+ return fmt.Errorf("clear org opt-in: %w", err)
+ }
+ for _, id := range commandIDs {
+ if _, err := tx.Exec(ctx,
+ `INSERT INTO org_command_optin (org_id, owner_id, command_id) VALUES ($1, $2, $3)
+ ON CONFLICT DO NOTHING`, orgID, ownerID, id); err != nil {
+ return fmt.Errorf("insert org opt-in: %w", err)
+ }
+ }
+ return nil
+ })
+}
diff --git a/internal/store/org_permissions.go b/internal/store/org_permissions.go
deleted file mode 100644
index b9b0da1..0000000
--- a/internal/store/org_permissions.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package store
-
-import (
- "context"
- "fmt"
-
- "github.com/jackc/pgx/v5"
-)
-
-// CurrentVersion returns the org's latest permission version (id and number).
-func (s *Store) CurrentVersion(ctx context.Context, orgID int64) (id int64, version int, err error) {
- err = s.pool.QueryRow(ctx,
- `SELECT id, version FROM org_permission_versions WHERE org_id = $1 ORDER BY version DESC LIMIT 1`,
- orgID).Scan(&id, &version)
- if err != nil {
- return 0, 0, notFoundOr(err, "current version")
- }
- return id, version, nil
-}
-
-// VersionCommandIDs returns the command ids in a version, split by tier.
-func (s *Store) VersionCommandIDs(ctx context.Context, versionID int64) (admin, member []int64, err error) {
- rows, err := s.pool.Query(ctx,
- `SELECT command_id, tier FROM org_permission_commands WHERE version_id = $1`, versionID)
- if err != nil {
- return nil, nil, fmt.Errorf("version commands: %w", err)
- }
- defer rows.Close()
- for rows.Next() {
- var id int64
- var tier string
- if err := rows.Scan(&id, &tier); err != nil {
- return nil, nil, err
- }
- if tier == "admin" {
- admin = append(admin, id)
- } else {
- member = append(member, id)
- }
- }
- return admin, member, rows.Err()
-}
-
-// VersionNumber returns the version number for a permission version id.
-func (s *Store) VersionNumber(ctx context.Context, versionID int64) (int, error) {
- var v int
- err := s.pool.QueryRow(ctx,
- `SELECT version FROM org_permission_versions WHERE id = $1`, versionID).Scan(&v)
- if err != nil {
- return 0, notFoundOr(err, "version number")
- }
- return v, nil
-}
-
-// VersionNote is a changelog entry.
-type VersionNote struct {
- Version int
- Note string
-}
-
-// VersionNotesSince returns the notes for org versions newer than afterVersion,
-// oldest first — the changelog an owner reviews before re-consenting.
-func (s *Store) VersionNotesSince(ctx context.Context, orgID int64, afterVersion int) ([]VersionNote, error) {
- rows, err := s.pool.Query(ctx,
- `SELECT version, note FROM org_permission_versions
- WHERE org_id = $1 AND version > $2 ORDER BY version`, orgID, afterVersion)
- if err != nil {
- return nil, fmt.Errorf("version notes: %w", err)
- }
- return collectRows(rows, func(r pgx.Row) (VersionNote, error) {
- var n VersionNote
- err := r.Scan(&n.Version, &n.Note)
- return n, err
- })
-}
-
-// PublishVersion creates the org's next permission version with the given
-// admin/member command sets, returning the new version number.
-func (s *Store) PublishVersion(ctx context.Context, orgID int64, note string, createdBy int64, adminIDs, memberIDs []int64) (int, error) {
- var next int
- err := s.inTx(ctx, func(tx pgx.Tx) error {
- if err := tx.QueryRow(ctx,
- `SELECT COALESCE(max(version), 0) + 1 FROM org_permission_versions WHERE org_id = $1`,
- orgID).Scan(&next); err != nil {
- return fmt.Errorf("next version: %w", err)
- }
- var versionID int64
- if err := tx.QueryRow(ctx,
- `INSERT INTO org_permission_versions (org_id, version, note, created_by)
- VALUES ($1, $2, $3, $4) RETURNING id`,
- orgID, next, note, createdBy).Scan(&versionID); err != nil {
- return fmt.Errorf("insert version: %w", err)
- }
- insert := func(ids []int64, tier string) error {
- for _, id := range ids {
- if _, err := tx.Exec(ctx,
- `INSERT INTO org_permission_commands (version_id, command_id, tier) VALUES ($1, $2, $3)
- ON CONFLICT DO NOTHING`, versionID, id, tier); err != nil {
- return err
- }
- }
- return nil
- }
- if err := insert(adminIDs, "admin"); err != nil {
- return fmt.Errorf("insert admin commands: %w", err)
- }
- if err := insert(memberIDs, "member"); err != nil {
- return fmt.Errorf("insert member commands: %w", err)
- }
- return nil
- })
- if err != nil {
- return 0, err
- }
- return next, nil
-}
diff --git a/internal/store/org_repeaters.go b/internal/store/org_repeaters.go
index 1978c53..d517fc4 100644
--- a/internal/store/org_repeaters.go
+++ b/internal/store/org_repeaters.go
@@ -7,48 +7,46 @@ import (
"github.com/jackc/pgx/v5"
)
-// RepeaterOrg describes an org a repeater is contributed to, with the version
-// the owner consented to vs the org's current version (current > consented means
-// re-consent is available).
+// A repeater participates in an org iff its owner is a member of that org and the
+// owner hasn't opted it out (no org_repeater_excludes row). This file builds the
+// org↔repeater listings around that rule and manages the opt-out set.
+
+// RepeaterOrg is an org a repeater participates in.
type RepeaterOrg struct {
- OrgID int64
- OrgSlug string
- OrgName string
- ConsentedVersion int
- CurrentVersion int
+ OrgID int64
+ OrgSlug string
+ OrgName string
}
-// NeedsReconsent reports whether the org has published a newer version than the
-// owner consented to.
-func (r RepeaterOrg) NeedsReconsent() bool { return r.CurrentVersion > r.ConsentedVersion }
+// RepeaterOrgMembership is an org the repeater's owner belongs to, with whether
+// the owner has opted this repeater out of it — drives the per-org include/exclude
+// toggles on the owner's repeater/share pages.
+type RepeaterOrgMembership struct {
+ OrgID int64
+ OrgSlug string
+ OrgName string
+ Excluded bool
+}
-// ContributeRepeater contributes a repeater to an org pinned to consentedVersionID
-// (also used to re-consent: re-pins to a newer version).
-func (s *Store) ContributeRepeater(ctx context.Context, orgID, repeaterID, consentedVersionID, contributedBy int64) error {
- _, err := s.pool.Exec(ctx, `
- INSERT INTO org_repeaters (org_id, repeater_id, consented_version_id, contributed_by)
- VALUES ($1, $2, $3, $4)
- ON CONFLICT (org_id, repeater_id)
- DO UPDATE SET consented_version_id = EXCLUDED.consented_version_id,
- contributed_by = EXCLUDED.contributed_by, contributed_at = now()`,
- orgID, repeaterID, consentedVersionID, contributedBy)
+// SetRepeaterOrgExcluded opts a repeater out of (excluded=true) or back into
+// (excluded=false) an org. Idempotent.
+func (s *Store) SetRepeaterOrgExcluded(ctx context.Context, orgID, repeaterID int64, excluded bool) error {
+ var err error
+ if excluded {
+ _, err = s.pool.Exec(ctx,
+ `INSERT INTO org_repeater_excludes (org_id, repeater_id) VALUES ($1, $2)
+ ON CONFLICT DO NOTHING`, orgID, repeaterID)
+ } else {
+ _, err = s.pool.Exec(ctx,
+ `DELETE FROM org_repeater_excludes WHERE org_id = $1 AND repeater_id = $2`, orgID, repeaterID)
+ }
if err != nil {
- return fmt.Errorf("contribute repeater: %w", err)
+ return fmt.Errorf("set repeater org excluded: %w", err)
}
return nil
}
-// WithdrawRepeater removes a repeater from an org.
-func (s *Store) WithdrawRepeater(ctx context.Context, orgID, repeaterID int64) error {
- _, err := s.pool.Exec(ctx,
- `DELETE FROM org_repeaters WHERE org_id = $1 AND repeater_id = $2`, orgID, repeaterID)
- if err != nil {
- return fmt.Errorf("withdraw repeater: %w", err)
- }
- return nil
-}
-
-// OrgRepeaterInfo is a contributed repeater shown on the org page.
+// OrgRepeaterInfo is a participating repeater shown on the org page.
type OrgRepeaterInfo struct {
RepeaterID int64
RepeaterPublicID string
@@ -58,16 +56,17 @@ type OrgRepeaterInfo struct {
Lat, Lon float64
}
-// ListOrgRepeaters returns the repeaters contributed to an org (with location
-// when the owner consented to storing it).
+// ListOrgRepeaters returns the repeaters participating in an org (owned by a
+// member, not opted out), with location when the owner stored it.
func (s *Store) ListOrgRepeaters(ctx context.Context, orgID int64) ([]OrgRepeaterInfo, error) {
rows, err := s.pool.Query(ctx, `
SELECT r.id, r.public_id, r.name, COALESCE(NULLIF(ou.display_name, ''), ou.username, '?'),
r.latitude, r.longitude
- FROM org_repeaters orp
- JOIN repeaters r ON r.id = orp.repeater_id
+ FROM repeaters r
+ JOIN org_members om ON om.org_id = $1 AND om.user_id = r.owner_id
JOIN users ou ON ou.id = r.owner_id
- WHERE orp.org_id = $1
+ WHERE NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
+ WHERE e.org_id = $1 AND e.repeater_id = r.id)
ORDER BY r.name`, orgID)
if err != nil {
return nil, fmt.Errorf("list org repeaters: %w", err)
@@ -89,16 +88,18 @@ func setLocation(ri *OrgRepeaterInfo, lat, lon *float64) {
}
}
-// ListPublicMapRepeaters returns the contributed repeaters an org may show on
+// ListPublicMapRepeaters returns the participating repeaters an org may show on
// its public map: those whose owner opted into public_map and have coordinates.
func (s *Store) ListPublicMapRepeaters(ctx context.Context, orgID int64) ([]OrgRepeaterInfo, error) {
rows, err := s.pool.Query(ctx, `
SELECT r.id, r.name, COALESCE(NULLIF(ou.display_name, ''), ou.username, '?'),
r.latitude, r.longitude
- FROM org_repeaters orp
- JOIN repeaters r ON r.id = orp.repeater_id
+ FROM repeaters r
+ JOIN org_members om ON om.org_id = $1 AND om.user_id = r.owner_id
JOIN users ou ON ou.id = r.owner_id
- WHERE orp.org_id = $1 AND r.public_map AND r.latitude IS NOT NULL AND r.longitude IS NOT NULL
+ WHERE r.public_map AND r.latitude IS NOT NULL AND r.longitude IS NOT NULL
+ AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
+ WHERE e.org_id = $1 AND e.repeater_id = r.id)
ORDER BY r.name`, orgID)
if err != nil {
return nil, fmt.Errorf("list public map repeaters: %w", err)
@@ -112,62 +113,46 @@ func (s *Store) ListPublicMapRepeaters(ctx context.Context, orgID int64) ([]OrgR
})
}
-// ConsentedVersionID returns the permission version a repeater is pinned to for
-// an org, or (0, false) if it isn't contributed there.
-func (s *Store) ConsentedVersionID(ctx context.Context, orgID, repeaterID int64) (int64, bool, error) {
- var id int64
- err := s.pool.QueryRow(ctx,
- `SELECT consented_version_id FROM org_repeaters WHERE org_id = $1 AND repeater_id = $2`,
- orgID, repeaterID).Scan(&id)
- if err != nil {
- return 0, false, nil //nolint:nilerr // absence is not an error here
- }
- return id, true, nil
-}
-
-// OwnedRepeatersNeedingReconsent returns the set of repeater ids owned by the
-// user that are contributed to an org which has published a newer version than
-// the owner consented to.
-func (s *Store) OwnedRepeatersNeedingReconsent(ctx context.Context, ownerID int64) (map[int64]bool, error) {
- rows, err := s.pool.Query(ctx, `
- SELECT DISTINCT orp.repeater_id
- FROM org_repeaters orp
- JOIN repeaters r ON r.id = orp.repeater_id AND r.owner_id = $1
- JOIN org_permission_versions cv ON cv.id = orp.consented_version_id
- WHERE cv.version < (SELECT max(version) FROM org_permission_versions WHERE org_id = orp.org_id)`,
- ownerID)
- if err != nil {
- return nil, fmt.Errorf("reconsent set: %w", err)
- }
- defer rows.Close()
- out := map[int64]bool{}
- for rows.Next() {
- var id int64
- if err := rows.Scan(&id); err != nil {
- return nil, err
- }
- out[id] = true
- }
- return out, rows.Err()
-}
-
-// ListRepeaterOrgs returns the orgs a repeater is contributed to, with consented
-// vs current version numbers.
+// ListRepeaterOrgs returns the orgs a repeater participates in (owner is a member
+// and hasn't opted it out).
func (s *Store) ListRepeaterOrgs(ctx context.Context, repeaterID int64) ([]RepeaterOrg, error) {
rows, err := s.pool.Query(ctx, `
- SELECT o.id, o.slug, o.name, cv.version,
- (SELECT max(version) FROM org_permission_versions WHERE org_id = o.id)
- FROM org_repeaters orp
- JOIN organizations o ON o.id = orp.org_id
- JOIN org_permission_versions cv ON cv.id = orp.consented_version_id
- WHERE orp.repeater_id = $1
+ SELECT o.id, o.slug, o.name
+ FROM repeaters r
+ JOIN org_members om ON om.user_id = r.owner_id
+ JOIN organizations o ON o.id = om.org_id
+ WHERE r.id = $1
+ AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
+ WHERE e.org_id = o.id AND e.repeater_id = r.id)
ORDER BY o.name`, repeaterID)
if err != nil {
return nil, fmt.Errorf("list repeater orgs: %w", err)
}
return collectRows(rows, func(r pgx.Row) (RepeaterOrg, error) {
var ro RepeaterOrg
- err := r.Scan(&ro.OrgID, &ro.OrgSlug, &ro.OrgName, &ro.ConsentedVersion, &ro.CurrentVersion)
+ err := r.Scan(&ro.OrgID, &ro.OrgSlug, &ro.OrgName)
return ro, err
})
}
+
+// ListRepeaterOrgMemberships returns every org the repeater's owner belongs to,
+// flagged with whether the owner has opted this repeater out of it.
+func (s *Store) ListRepeaterOrgMemberships(ctx context.Context, repeaterID int64) ([]RepeaterOrgMembership, error) {
+ rows, err := s.pool.Query(ctx, `
+ SELECT o.id, o.slug, o.name,
+ EXISTS (SELECT 1 FROM org_repeater_excludes e
+ WHERE e.org_id = o.id AND e.repeater_id = r.id)
+ FROM repeaters r
+ JOIN org_members om ON om.user_id = r.owner_id
+ JOIN organizations o ON o.id = om.org_id
+ WHERE r.id = $1
+ ORDER BY o.name`, repeaterID)
+ if err != nil {
+ return nil, fmt.Errorf("list repeater org memberships: %w", err)
+ }
+ return collectRows(rows, func(r pgx.Row) (RepeaterOrgMembership, error) {
+ var m RepeaterOrgMembership
+ err := r.Scan(&m.OrgID, &m.OrgSlug, &m.OrgName, &m.Excluded)
+ return m, err
+ })
+}
diff --git a/internal/store/orgs.go b/internal/store/orgs.go
index bbcbb01..ca9916d 100644
--- a/internal/store/orgs.go
+++ b/internal/store/orgs.go
@@ -124,25 +124,8 @@ func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*O
o.ID, creatorID); err != nil {
return fmt.Errorf("add creator: %w", err)
}
-
- // Seed version 1 from the catalog default sets.
- var versionID int64
- if err := tx.QueryRow(ctx,
- `INSERT INTO org_permission_versions (org_id, version, note, created_by)
- VALUES ($1, 1, 'Initial policy', $2) RETURNING id`,
- o.ID, creatorID).Scan(&versionID); err != nil {
- return fmt.Errorf("seed version: %w", err)
- }
- if _, err := tx.Exec(ctx,
- `INSERT INTO org_permission_commands (version_id, command_id, tier)
- SELECT $1, id, 'admin' FROM command_catalog WHERE in_org_admin_default`, versionID); err != nil {
- return fmt.Errorf("seed admin commands: %w", err)
- }
- if _, err := tx.Exec(ctx,
- `INSERT INTO org_permission_commands (version_id, command_id, tier)
- SELECT $1, id, 'member' FROM command_catalog WHERE in_org_member_default`, versionID); err != nil {
- return fmt.Errorf("seed member commands: %w", err)
- }
+ // No permission policy to seed: what an org may run is the site-wide
+ // catalog ceiling, and owners restrict per org via org_command_optin.
return nil
})
if err != nil {
@@ -302,7 +285,10 @@ func (s *Store) ListPublicOrgsPage(ctx context.Context, p OrgListParams) ([]OrgS
FROM (
SELECT o.id, o.slug, o.name, o.description, o.region, o.created_at,
(SELECT count(*) FROM org_members m WHERE m.org_id = o.id) AS member_count,
- (SELECT count(*) FROM org_repeaters orp WHERE orp.org_id = o.id) AS repeater_count
+ (SELECT count(*) FROM repeaters r
+ JOIN org_members om ON om.org_id = o.id AND om.user_id = r.owner_id
+ WHERE NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
+ WHERE e.org_id = o.id AND e.repeater_id = r.id)) AS repeater_count
FROM organizations o
%s
) t
@@ -333,7 +319,10 @@ func (s *Store) ListPublicOrgsPage(ctx context.Context, p OrgListParams) ([]OrgS
func (s *Store) OrgCounts(ctx context.Context, orgID int64) (members, repeaters int, err error) {
err = s.pool.QueryRow(ctx, `
SELECT (SELECT count(*) FROM org_members WHERE org_id = $1),
- (SELECT count(*) FROM org_repeaters WHERE org_id = $1)`, orgID).
+ (SELECT count(*) FROM repeaters r
+ JOIN org_members om ON om.org_id = $1 AND om.user_id = r.owner_id
+ WHERE NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
+ WHERE e.org_id = $1 AND e.repeater_id = r.id))`, orgID).
Scan(&members, &repeaters)
if err != nil {
return 0, 0, fmt.Errorf("org counts: %w", err)
diff --git a/internal/store/orgs_resolution_test.go b/internal/store/orgs_resolution_test.go
index 8c95903..8789093 100644
--- a/internal/store/orgs_resolution_test.go
+++ b/internal/store/orgs_resolution_test.go
@@ -48,10 +48,22 @@ func TestOrgCommandResolution(t *testing.T) {
}
return id
}
- // poweroff is a risky, owner-only command that's in no org tier — used to
- // check that even an org admin can't run a command outside the policy.
+ // poweroff is a risky, owner-only command kept out of both org tiers — used to
+ // check that even an org admin can't run a command outside the site ceiling.
setRadio, advert, poweroff, setTx := cmdID("set.radio"), cmdID("advert"), cmdID("poweroff"), cmdID("set.tx")
+ // Set the site ceiling: admin tier = {set.radio, set.tx}, member tier = {advert},
+ // poweroff in neither. (risky/share flags don't matter for these checks.)
+ setCeiling := func(id int64, member, admin bool) {
+ if err := st.UpdateCommandFlags(ctx, id, false, false, member, admin); err != nil {
+ t.Fatalf("set ceiling %d: %v", id, err)
+ }
+ }
+ setCeiling(setRadio, false, true)
+ setCeiling(setTx, false, true)
+ setCeiling(advert, true, false)
+ setCeiling(poweroff, false, false)
+
mkUser := func(name string) int64 {
u, err := st.CreateUser(ctx, name, "")
if err != nil {
@@ -82,15 +94,7 @@ func TestOrgCommandResolution(t *testing.T) {
if err := st.AddOrgMember(ctx, org.ID, plainM, "member"); err != nil {
t.Fatal(err)
}
-
- // Controlled v2: admin={set.radio}, member={advert}; contribute pinned to it.
- if _, err := st.PublishVersion(ctx, org.ID, "v2", owner, []int64{setRadio}, []int64{advert}); err != nil {
- t.Fatal(err)
- }
- vid, _, _ := st.CurrentVersion(ctx, org.ID)
- if err := st.ContributeRepeater(ctx, org.ID, rep.ID, vid, owner); err != nil {
- t.Fatal(err)
- }
+ // The owner's repeater participates in the org automatically (no opt-out).
can := func(u, c int64) bool {
ok, err := st.CanSendCommand(ctx, u, rep.ID, c)
@@ -108,9 +112,10 @@ func TestOrgCommandResolution(t *testing.T) {
// Owner: anything.
check("owner/poweroff", can(owner, poweroff), true)
- // Org-admin: admin tier + member tier (⊇), but not commands outside policy.
+ // Org-admin: admin tier + member tier (⊇), but not commands outside the ceiling.
check("admin/set.radio", can(adminM, setRadio), true)
check("admin/advert", can(adminM, advert), true)
+ check("admin/set.tx", can(adminM, setTx), true)
check("admin/poweroff", can(adminM, poweroff), false)
// Plain member: member tier only.
check("member/advert", can(plainM, advert), true)
@@ -118,24 +123,29 @@ func TestOrgCommandResolution(t *testing.T) {
// Outsider: nothing.
check("outsider/advert", can(outsider, advert), false)
- // Add set.tx to admin in v3; repeater still pinned to v2 → blocked until re-consent.
- if _, err := st.PublishVersion(ctx, org.ID, "v3 add set.tx", owner, []int64{setRadio, setTx}, []int64{advert}); err != nil {
+ // Owner opts the org into only {advert}: the admin loses the admin-tier
+ // commands not in the list, but keeps advert.
+ if err := st.SetOrgOptIn(ctx, org.ID, owner, []int64{advert}); err != nil {
t.Fatal(err)
}
- check("admin/set.tx before reconsent", can(adminM, setTx), false)
- v3, _, _ := st.CurrentVersion(ctx, org.ID)
- if err := st.ContributeRepeater(ctx, org.ID, rep.ID, v3, owner); err != nil { // re-consent
+ check("admin/set.radio with opt-in", can(adminM, setRadio), false)
+ check("admin/advert with opt-in", can(adminM, advert), true)
+ // Clearing the opt-in restores the full ceiling.
+ if err := st.SetOrgOptIn(ctx, org.ID, owner, nil); err != nil {
t.Fatal(err)
}
- check("admin/set.tx after reconsent", can(adminM, setTx), true)
+ check("admin/set.radio after clear", can(adminM, setRadio), true)
- // Remove set.radio in v4 (admin={set.tx}); removal auto-applies even though
- // the repeater is still consented to v3 (which had set.radio).
- if _, err := st.PublishVersion(ctx, org.ID, "v4 drop set.radio", owner, []int64{setTx}, []int64{advert}); err != nil {
+ // Opting the repeater out of the org blocks all org access regardless of tier.
+ if err := st.SetRepeaterOrgExcluded(ctx, org.ID, rep.ID, true); err != nil {
t.Fatal(err)
}
- check("admin/set.radio after removal", can(adminM, setRadio), false)
- check("admin/set.tx still", can(adminM, setTx), true)
+ check("admin/advert when excluded", can(adminM, advert), false)
+ check("member/advert when excluded", can(plainM, advert), false)
+ if err := st.SetRepeaterOrgExcluded(ctx, org.ID, rep.ID, false); err != nil {
+ t.Fatal(err)
+ }
+ check("admin/advert when re-included", can(adminM, advert), true)
}
func TestOrgRepeaterAccess(t *testing.T) {
@@ -150,8 +160,7 @@ func TestOrgRepeaterAccess(t *testing.T) {
}
org, _ := st.CreateOrg(ctx, "Org", owner.ID)
_ = st.AddOrgMember(ctx, org.ID, member.ID, "member")
- vid, _, _ := st.CurrentVersion(ctx, org.ID)
- _ = st.ContributeRepeater(ctx, org.ID, rep.ID, vid, owner.ID)
+ // The owner's repeater participates in the org automatically.
// Member can fetch (and thus operate) the repeater via org access...
if _, err := st.GetRepeaterForUser(ctx, member.ID, rep.ID); err != nil {
diff --git a/internal/store/repeaters.go b/internal/store/repeaters.go
index 06b44e0..5b0910e 100644
--- a/internal/store/repeaters.go
+++ b/internal/store/repeaters.go
@@ -151,8 +151,12 @@ func (s *Store) GetRepeaterForUser(ctx context.Context, userID, repeaterID int64
WHERE r.id = $2
AND (r.owner_id = $1
OR r.id IN (SELECT repeater_id FROM repeater_shares WHERE user_id = $1)
- OR r.id IN (SELECT orp.repeater_id FROM org_repeaters orp
- JOIN org_members om ON om.org_id = orp.org_id AND om.user_id = $1))`,
+ OR EXISTS (SELECT 1
+ FROM org_members ownm
+ JOIN org_members usrm ON usrm.org_id = ownm.org_id AND usrm.user_id = $1
+ WHERE ownm.user_id = r.owner_id
+ AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
+ WHERE e.org_id = ownm.org_id AND e.repeater_id = r.id)))`,
userID, repeaterID)
r, err := scanRepeater(row)
if err != nil {
diff --git a/internal/store/reset.go b/internal/store/reset.go
new file mode 100644
index 0000000..9507f1d
--- /dev/null
+++ b/internal/store/reset.go
@@ -0,0 +1,59 @@
+package store
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// resetPreservedTables are the tables Reset keeps: everything needed to still sign
+// in (users + their passkeys + live sessions), the server-wide MeshCore identity
+// (so re-added repeaters still trust MeshTender), the command catalog (site config,
+// not user data), and goose's migration bookkeeping. Everything else — orgs,
+// repeaters, shares, config profiles, logs, console/auth ephemera — is wiped.
+var resetPreservedTables = map[string]bool{
+ "users": true,
+ "webauthn_credentials": true,
+ "sessions": true,
+ "server_identity": true,
+ "command_catalog": true,
+ "goose_db_version": true,
+}
+
+// Reset truncates all application data except the identity/login/catalog tables
+// (see resetPreservedTables). It's a development convenience: after a reset you can
+// still log in and your repeaters still trust the server, but you start fresh on
+// orgs, repeaters, and everything else. Discovers tables dynamically so new
+// migrations are covered automatically.
+func (s *Store) Reset(ctx context.Context) error {
+ rows, err := s.pool.Query(ctx,
+ `SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
+ if err != nil {
+ return fmt.Errorf("reset: list tables: %w", err)
+ }
+ tables, err := collectRows(rows, func(r pgx.Row) (string, error) {
+ var t string
+ return t, r.Scan(&t)
+ })
+ if err != nil {
+ return fmt.Errorf("reset: scan tables: %w", err)
+ }
+ var quoted []string
+ for _, t := range tables {
+ if !resetPreservedTables[t] {
+ quoted = append(quoted, pgx.Identifier{t}.Sanitize())
+ }
+ }
+ if len(quoted) == 0 {
+ return nil
+ }
+ // RESTART IDENTITY resets sequences; CASCADE handles FK ordering (no preserved
+ // table references a wiped one, so CASCADE never reaches the kept tables).
+ stmt := "TRUNCATE " + strings.Join(quoted, ", ") + " RESTART IDENTITY CASCADE"
+ if _, err := s.pool.Exec(ctx, stmt); err != nil {
+ return fmt.Errorf("reset: truncate: %w", err)
+ }
+ return nil
+}
diff --git a/internal/store/reset_test.go b/internal/store/reset_test.go
new file mode 100644
index 0000000..64ed7cc
--- /dev/null
+++ b/internal/store/reset_test.go
@@ -0,0 +1,74 @@
+package store
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestReset(t *testing.T) {
+ t.Parallel()
+ st, ctx := orgTestStore(t)
+
+ count := func(table string) int {
+ var n int
+ if err := st.pool.QueryRow(ctx, "SELECT count(*) FROM "+table).Scan(&n); err != nil {
+ t.Fatalf("count %s: %v", table, err)
+ }
+ return n
+ }
+
+ // Seed preserved data (user + server identity) and disposable data (repeater + org).
+ owner, err := st.CreateUser(ctx, "owner", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.InsertServerIdentity(ctx, strings.Repeat("a", 64), []byte("sealed")); err != nil {
+ t.Fatal(err)
+ }
+ rep, err := st.CreateRepeater(ctx, &Repeater{
+ OwnerID: owner.ID, Name: "R", PublicKeyHex: strings.Repeat("b", 64),
+ RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.CreateOrg(ctx, "Region", owner.ID); err != nil {
+ t.Fatal(err)
+ }
+ _ = rep
+
+ catalogBefore := count("command_catalog")
+ if catalogBefore == 0 {
+ t.Fatal("expected a seeded command catalog")
+ }
+
+ if err := st.Reset(ctx); err != nil {
+ t.Fatalf("reset: %v", err)
+ }
+
+ // Preserved: login + identity + catalog survive.
+ if got := count("users"); got != 1 {
+ t.Errorf("users after reset = %d, want 1", got)
+ }
+ if got := count("server_identity"); got != 1 {
+ t.Errorf("server_identity after reset = %d, want 1", got)
+ }
+ if got := count("command_catalog"); got != catalogBefore {
+ t.Errorf("command_catalog after reset = %d, want %d", got, catalogBefore)
+ }
+ // Wiped: user content is gone.
+ if got := count("repeaters"); got != 0 {
+ t.Errorf("repeaters after reset = %d, want 0", got)
+ }
+ if got := count("organizations"); got != 0 {
+ t.Errorf("organizations after reset = %d, want 0", got)
+ }
+ if got := count("org_members"); got != 0 {
+ t.Errorf("org_members after reset = %d, want 0", got)
+ }
+
+ // The kept user can still be looked up (login still works).
+ if _, err := st.GetUserByID(ctx, owner.ID); err != nil {
+ t.Errorf("GetUserByID after reset: %v", err)
+ }
+}
diff --git a/internal/store/share_commands.go b/internal/store/share_commands.go
index f8e56db..3ac724f 100644
--- a/internal/store/share_commands.go
+++ b/internal/store/share_commands.go
@@ -11,10 +11,11 @@ import (
// to repeaterID. Allowed if any of:
// - they own the repeater (any command), or
// - a share grants them that specific command, or
-// - the repeater is contributed to an org they're a member of, and the command
-// is in BOTH the consented and the current policy version for their effective
-// tier (member → member tier; admin → member OR admin tier). This implements
-// effective = consented ∩ current, with admins ⊇ members.
+// - the repeater participates in an org they and the owner both belong to (the
+// owner is a member and hasn't excluded the repeater), the command is within
+// the site ceiling for their tier (member → org_member_allowed; admin →
+// org_member_allowed OR org_admin_allowed), AND the owner either set no opt-in
+// list for that org (permissive) or listed this command.
func (s *Store) CanSendCommand(ctx context.Context, userID, repeaterID, commandID int64) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx, `
@@ -23,20 +24,20 @@ func (s *Store) CanSendCommand(ctx context.Context, userID, repeaterID, commandI
OR EXISTS (SELECT 1 FROM share_commands WHERE repeater_id = $2 AND user_id = $1 AND command_id = $3)
OR EXISTS (
SELECT 1
- FROM org_repeaters orp
- JOIN org_members om ON om.org_id = orp.org_id AND om.user_id = $1
- JOIN org_permission_commands consented
- ON consented.version_id = orp.consented_version_id
- AND consented.command_id = $3
- AND (consented.tier = 'member' OR (om.role = 'admin' AND consented.tier = 'admin'))
- JOIN org_permission_versions cur
- ON cur.org_id = orp.org_id
- AND cur.version = (SELECT max(version) FROM org_permission_versions WHERE org_id = orp.org_id)
- JOIN org_permission_commands current
- ON current.version_id = cur.id
- AND current.command_id = $3
- AND (current.tier = 'member' OR (om.role = 'admin' AND current.tier = 'admin'))
- WHERE orp.repeater_id = $2
+ FROM repeaters r
+ JOIN org_members ownm ON ownm.user_id = r.owner_id -- owner's org memberships
+ JOIN org_members usrm ON usrm.org_id = ownm.org_id AND usrm.user_id = $1
+ JOIN command_catalog c ON c.id = $3
+ WHERE r.id = $2
+ AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
+ WHERE e.org_id = ownm.org_id AND e.repeater_id = r.id)
+ AND (c.org_member_allowed OR (usrm.role = 'admin' AND c.org_admin_allowed))
+ AND (
+ NOT EXISTS (SELECT 1 FROM org_command_optin o
+ WHERE o.org_id = ownm.org_id AND o.owner_id = r.owner_id)
+ OR EXISTS (SELECT 1 FROM org_command_optin o
+ WHERE o.org_id = ownm.org_id AND o.owner_id = r.owner_id AND o.command_id = $3)
+ )
)`,
userID, repeaterID, commandID).Scan(&ok)
if err != nil {
diff --git a/internal/store/shares.go b/internal/store/shares.go
index 4b734b6..7139a82 100644
--- a/internal/store/shares.go
+++ b/internal/store/shares.go
@@ -75,16 +75,19 @@ func (s *Store) RemoveShare(ctx context.Context, repeaterID, userID int64) error
// ShareCounts summarizes how widely a repeater is shared.
type ShareCounts struct {
Users int // direct user shares
- Orgs int // organization contributions
+ Orgs int // organizations this repeater participates in
}
-// RepeaterSharingCounts returns per-repeater share and org-contribution counts
+// RepeaterSharingCounts returns per-repeater share and org-participation counts
// for every repeater owned by ownerID, keyed by repeater id.
func (s *Store) RepeaterSharingCounts(ctx context.Context, ownerID int64) (map[int64]ShareCounts, error) {
rows, err := s.pool.Query(ctx, `
SELECT r.id,
(SELECT count(*) FROM repeater_shares rs WHERE rs.repeater_id = r.id),
- (SELECT count(*) FROM org_repeaters orp WHERE orp.repeater_id = r.id)
+ (SELECT count(*) FROM org_members om
+ WHERE om.user_id = r.owner_id
+ AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
+ WHERE e.org_id = om.org_id AND e.repeater_id = r.id))
FROM repeaters r WHERE r.owner_id = $1`, ownerID)
if err != nil {
return nil, fmt.Errorf("repeater sharing counts: %w", err)
diff --git a/internal/web/env.go b/internal/web/env.go
index 3936ecf..0111022 100644
--- a/internal/web/env.go
+++ b/internal/web/env.go
@@ -22,14 +22,14 @@ import (
"github.com/jleight/meshtender/internal/store"
)
-//go:embed templates/base.html templates/icons.html templates/org_tabs.html templates/org_public.html templates/org_config.html templates/org_permissions.html templates/org_repeaters.html
+//go:embed templates/base.html templates/icons.html templates/org_tabs.html templates/org_public.html templates/org_config.html templates/org_repeaters.html
var sharedTemplatesFS embed.FS
// sharedPages are full content pages (not just layout partials) that more than
// one surface renders. They're composed onto the base layout for every surface,
// so the root host (anonymous) and the app host (signed-in) can render the same
// public org page without duplicating the template.
-var sharedPages = []string{"templates/org_public.html", "templates/org_config.html", "templates/org_permissions.html", "templates/org_repeaters.html"}
+var sharedPages = []string{"templates/org_public.html", "templates/org_config.html", "templates/org_repeaters.html"}
//go:embed static/*
var staticFS embed.FS
diff --git a/internal/web/orgview.go b/internal/web/orgview.go
index b260532..6afd3b6 100644
--- a/internal/web/orgview.go
+++ b/internal/web/orgview.go
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"net/http"
- "sort"
"strconv"
"github.com/jleight/meshtender/internal/store"
@@ -127,131 +126,3 @@ func PreviewLatLon(r *http.Request) (lat, lon float64, ok bool) {
return lat, lon, err1 == nil && err2 == nil
}
-// CmdCell is one command in a feature-table cell (the "feature-table" partial in
-// base.html renders .Template/.Description/.Risky).
-type CmdCell struct {
- Template string
- Description string // shown as a hover tooltip
- Risky bool
-}
-
-// FeatureRow is one feature's allowed commands bucketed by operation, for the
-// read-only feature×operation tables (consent, requested-access, contribute).
-type FeatureRow struct {
- Feature string
- Read, Write, Delete, Action []CmdCell
-}
-
-// FeatureTableFor groups the commands in `allowed` (the id-set a single tier may
-// run) by feature × operation, ordered by featureOrder — one table per tier. This
-// is the canonical home for the consent/permission feature table so both the app
-// host and the root host (which can't import core) can build it.
-func FeatureTableFor(catalog []*store.Command, allowed map[int64]bool) []FeatureRow {
- byFeature := map[string]*FeatureRow{}
- var present []string
- for _, c := range catalog {
- if !allowed[c.ID] {
- continue
- }
- row := byFeature[c.Feature]
- if row == nil {
- row = &FeatureRow{Feature: c.Feature}
- byFeature[c.Feature] = row
- present = append(present, c.Feature)
- }
- cell := CmdCell{Template: c.Template, Description: c.Description, Risky: c.Risky}
- switch c.Operation {
- case "read":
- row.Read = append(row.Read, cell)
- case "delete":
- row.Delete = append(row.Delete, cell)
- case "action":
- row.Action = append(row.Action, cell)
- default: // "write"
- row.Write = append(row.Write, cell)
- }
- }
- orderFeatures(present)
- out := make([]FeatureRow, 0, len(present))
- for _, f := range present {
- out = append(out, *byFeature[f])
- }
- return out
-}
-
-// PermissionsView is an org's current requested-access policy for display: one
-// feature×operation table per tier. Admins inherit every member command, so the
-// admin table is member ∪ admin.
-type PermissionsView struct {
- Version int
- MemberFeatures []FeatureRow
- AdminFeatures []FeatureRow
- HasRisky bool // any granted command is risky (drives the warning copy)
-}
-
-// BuildPermissionsView loads the org's current permission version and builds the
-// member and admin feature tables.
-func BuildPermissionsView(ctx context.Context, st *store.Store, orgID int64) (PermissionsView, error) {
- versionID, version, err := st.CurrentVersion(ctx, orgID)
- if err != nil {
- return PermissionsView{}, err
- }
- adminIDs, memberIDs, err := st.VersionCommandIDs(ctx, versionID)
- if err != nil {
- return PermissionsView{}, err
- }
- catalog, err := st.ListCommands(ctx)
- if err != nil {
- return PermissionsView{}, err
- }
- member := idSet(memberIDs)
- adminUnion := idSet(adminIDs)
- for id := range member {
- adminUnion[id] = true
- }
- pv := PermissionsView{
- Version: version,
- MemberFeatures: FeatureTableFor(catalog, member),
- AdminFeatures: FeatureTableFor(catalog, adminUnion),
- }
- for _, c := range catalog {
- if c.Risky && adminUnion[c.ID] {
- pv.HasRisky = true
- break
- }
- }
- return pv, nil
-}
-
-func idSet(ids []int64) map[int64]bool {
- m := make(map[int64]bool, len(ids))
- for _, id := range ids {
- m[id] = true
- }
- return m
-}
-
-// featureOrder mirrors core's command_features.go display order so the public
-// permissions view groups features the same way the in-app review/editor does.
-// Unknown features sort after these, alphabetically.
-var featureOrder = []string{"Radio", "Routing", "Advertising", "Location", "GPS", "Clock",
- "Region", "Neighbors", "Sensors", "Identity", "Access", "Power", "Diagnostics", "Firmware"}
-
-func featureRank(f string) int {
- for i, x := range featureOrder {
- if x == f {
- return i
- }
- }
- return len(featureOrder)
-}
-
-func orderFeatures(present []string) {
- sort.SliceStable(present, func(i, j int) bool {
- ri, rj := featureRank(present[i]), featureRank(present[j])
- if ri != rj {
- return ri < rj
- }
- return present[i] < present[j]
- })
-}
diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html
index 6b9d2b0..6d8f1d6 100644
--- a/internal/web/templates/base.html
+++ b/internal/web/templates/base.html
@@ -187,37 +187,6 @@
{{end}}
-{{/* feature-table renders a []featureRow (commands grouped by feature × operation)
- for permission review/consent. Dot is the slice of rows. */}}
-{{define "feature-table"}}
-
-{{end}}
-
-{{/* feature-cell renders one cell of the feature table (a []cmdCell). Hovering a
- command shows its description via the title tooltip. */}}
-{{define "feature-cell"}}
-
-{{- if . }}{{range .}}
{{.Template}}{{if .Risky}} risky{{end}}
{{end}}{{else}}—{{end -}}
-
-{{end}}
-
{{/* repstatus renders confirmation provenance + access badges for a *store.Repeater. */}}
{{define "repstatus"}}
{{- if not .Confirmed -}}
diff --git a/internal/web/templates/org_permissions.html b/internal/web/templates/org_permissions.html
deleted file mode 100644
index b60252d..0000000
--- a/internal/web/templates/org_permissions.html
+++ /dev/null
@@ -1,43 +0,0 @@
-{{define "title"}}Requested access · {{.Org.Name}} · MeshTender{{end}}
-{{define "header"}}
-
- The commands this organization may run on a repeater you contribute, by tier
- (current policy v{{.Perms.Version}}). Members consent to this when they
- contribute a repeater; you stay on the version you consented to until you re-consent to changes.{{if .Perms.HasRisky}} Risky commands can take over or brick a node.{{end}}
-
- {{if .Reps.Full}}No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page.
+ {{if .Reps.Full}}No repeaters are shared yet. A member's repeaters are shared automatically; they can opt one out from its sharing page.
{{else}}No repeaters are shown publicly. Members can opt a repeater into the public map when editing it.{{end}}
{{end}}
diff --git a/internal/web/templates/org_tabs.html b/internal/web/templates/org_tabs.html
index 095b144..1c3645e 100644
--- a/internal/web/templates/org_tabs.html
+++ b/internal/web/templates/org_tabs.html
@@ -1,5 +1,5 @@
{{/* org-tabs renders the organization sub-navigation. Data: a map with Slug,
-Active ("home" | "repeaters" | "members" | "config" | "permissions") and IsMember
+Active ("home" | "repeaters" | "members" | "config") and IsMember
(the Members tab only shows for members — it exposes personal info). Nil-safe: a
missing map renders empty links (harmless for the empty-data compose test). Links
are same-host relative so they work on both the app host (signed-in) and the root
@@ -10,6 +10,5 @@ host (anonymous). */}}
{{if .IsMember}}