Files
pad/internal/server/app_upgrade_diff.go
T
xarmian a1c36fca04 fix(server): the upgrade diff states what a widened delegated access grants (BUG-3427) (#1817)
A widened delegated-access row told the owner the change was recorded and
granted nothing until delegated access is enabled (TASK-3399), but U5b-2
opened delegated sign-in. appAdmitDelegated grants the lesser of what the
person consented to and what the manifest offers now, and a consent is
capped at the level offered when it was given, so the row now says that
existing sign-ins keep their consented access and people who sign in after
approval can consent to the new level. Stale 'refused until TASK-3399'
comments in app_api.go and install_clients.go are corrected.

Tests pin the detail string and the behaviour behind it (a read consent
stays read after the widening; a write consent given after it writes).
Mutants: the old string, and admission granting the manifest's level, each
go red.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 19:45:01 -04:00

371 lines
13 KiB
Go

package server
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"sort"
"strings"
"github.com/PerpetualSoftware/pad/internal/appmanifest"
"github.com/PerpetualSoftware/pad/internal/models"
)
// The upgrade diff (SPEC-6 U8b2, DOC-3371 §2 "Upgrade"; TASK-3397).
//
// Classes (the spec, plus the lead's rulings of day 86):
// - "auto": applies without review: removing an event, an item action, an
// artifact, an access level (narrowing), or a companion collection (which
// is RELEASED to the workspace with its data, not deleted);
// - "review": anything else that changed: an addition, a widening, any URL,
// any artifact raw or normalized digest, prose, and an additive optional
// field on an existing companion. Prose is never classed as narrowing.
//
// Every upgrade, removals-only included, is applied only by the owner's
// confirm (ruling 3); "auto" means there is no diff to read, not no consent.
// Refused outright: a changed app id or origin, a changed companion slug, and
// any companion schema change other than additive optional fields (ruling 2).
const (
upgradeAuto = "auto"
upgradeReview = "review"
)
type upgradeDiffEntry struct {
Kind string `json:"kind"` // app, access, url, event, item_action, collection, field, artifact, config
Key string `json:"key"` // what changed: an event name, a key, a field "coll.key"
Change string `json:"change"` // added, removed, changed, widened, narrowed, released
Class string `json:"class"` // auto or review
Detail string `json:"detail,omitempty"`
}
// upgradeSchemaAdd is one additive optional field for an existing companion.
type upgradeSchemaAdd struct {
CollectionKey string
CollectionSlug string
Field models.FieldDef
}
// upgradeDiff is the full comparison.
type upgradeDiff struct {
Entries []upgradeDiffEntry
SchemaAdds []upgradeSchemaAdd
Released []string // companion collection slugs released to the workspace
ChangedArtifacts map[string]bool // artifact keys that land as new drafts
ReviewRequired bool
}
func (d *upgradeDiff) add(kind, key, change, class, detail string) {
d.Entries = append(d.Entries, upgradeDiffEntry{Kind: kind, Key: key, Change: change, Class: class, Detail: detail})
if class == upgradeReview {
d.ReviewRequired = true
}
}
// storedArtifactDigest is one artifact's digests as the install recorded them.
type storedArtifactDigest struct {
Raw string `json:"raw"`
Normalized string `json:"normalized"`
}
func refuseUpgrade(path, format string, args ...any) *appInstallError {
e := installErr(http.StatusUnprocessableEntity, "upgrade_not_supported", format, args...)
e.path = path
return e
}
func accessRank(a string) int {
switch a {
case "write":
return 2
case "read":
return 1
}
return 0
}
// diffUpgrade compares the installed manifest with the new one, using the
// fresh preview for the new artifacts' digests.
func diffUpgrade(old, next *appmanifest.Manifest, oldDigests map[string]storedArtifactDigest, fresh *appPreview) (*upgradeDiff, error) {
d := &upgradeDiff{ChangedArtifacts: map[string]bool{}}
if old.ID != next.ID {
return nil, refuseUpgrade("id", "the app id changed (%q to %q); an upgrade is the same app", old.ID, next.ID)
}
if old.Origin != next.Origin {
return nil, refuseUpgrade("base_url", "the app origin changed (%q to %q); the origin is the app's identity", old.Origin, next.Origin)
}
// Prose and URLs: always review when changed.
prose := []struct{ key, a, b string }{
{"title", old.Title, next.Title}, {"description", old.Description, next.Description},
{"publisher", old.Publisher, next.Publisher},
}
for _, p := range prose {
if p.a != p.b {
d.add("app", p.key, "changed", upgradeReview, fmt.Sprintf("%q to %q", p.a, p.b))
}
}
urls := []struct{ key, a, b string }{
{"homepage", old.Homepage, next.Homepage}, {"webhook_url", old.WebhookURL, next.WebhookURL}, {"docs", old.Docs, next.Docs},
}
for _, u := range urls {
if u.a != u.b {
d.add("url", u.key, "changed", upgradeReview, fmt.Sprintf("%q to %q", u.a, u.b))
}
}
if !sameStringSet(old.RedirectURIs, next.RedirectURIs) {
d.add("url", "redirect_uris", "changed", upgradeReview, fmt.Sprintf("%v to %v", old.RedirectURIs, next.RedirectURIs))
}
if !reflect.DeepEqual(old.MinContract, next.MinContract) {
d.add("app", "min_contract", "changed", upgradeReview, "")
}
if !jsonEqual(old.ConfigSchema, next.ConfigSchema) {
d.add("config", "config_schema", "changed", upgradeReview, "")
}
// Access levels: widening needs review, narrowing applies.
for _, a := range []struct{ key, from, to string }{
{"service", old.Scopes.Service.Access, next.Scopes.Service.Access},
{"delegated", old.Scopes.Delegated.Access, next.Scopes.Delegated.Access},
} {
switch {
case accessRank(a.to) > accessRank(a.from):
detail := fmt.Sprintf("%q to %q", a.from, a.to)
if a.key == "delegated" {
// What appAdmitDelegated grants: the lesser of what the person
// consented to and what the manifest offers now. A consent was
// capped at the level offered when it was given, so a widening
// leaves every existing sign-in where it is; only a person who
// signs in after the approval can consent to the new level
// (BUG-3427).
detail += fmt.Sprintf("; people already signed in through the app keep the access they consented to, and people who sign in after you approve can consent to up to %q", a.to)
}
d.add("access", a.key, "widened", upgradeReview, detail)
case accessRank(a.to) < accessRank(a.from):
d.add("access", a.key, "narrowed", upgradeAuto, fmt.Sprintf("%q to %q", a.from, a.to))
}
}
// Events, by name.
// A name may be declared more than once (the manifest allows it): its
// collections are the union of every declaration, or a widening could hide
// behind a duplicate (codex r1 on U8b2).
unionEvents := func(evs []appmanifest.Event) map[string]appmanifest.Event {
out := map[string]appmanifest.Event{}
for _, e := range evs {
u := out[e.Name]
u.Name = e.Name
for _, c := range e.Collections {
if !subsetOf([]string{c}, u.Collections) {
u.Collections = append(u.Collections, c)
}
}
out[e.Name] = u
}
return out
}
oldEv, newEv := unionEvents(old.Events), unionEvents(next.Events)
for _, name := range upgradeSortedKeys(oldEv) {
if _, ok := newEv[name]; !ok {
d.add("event", name, "removed", upgradeAuto, "")
}
}
for _, name := range upgradeSortedKeys(newEv) {
o, ok := oldEv[name]
switch {
case !ok:
d.add("event", name, "added", upgradeReview, "")
case !sameStringSet(o.Collections, newEv[name].Collections):
if subsetOf(newEv[name].Collections, o.Collections) {
d.add("event", name, "narrowed", upgradeAuto, "")
} else {
d.add("event", name, "widened", upgradeReview, "")
}
}
}
// Item actions, by key.
oldAct, newAct := map[string]appmanifest.ItemAction{}, map[string]appmanifest.ItemAction{}
for _, a := range old.ItemActions {
oldAct[a.Key] = a
}
for _, a := range next.ItemActions {
newAct[a.Key] = a
}
for _, k := range upgradeSortedKeys(oldAct) {
if _, ok := newAct[k]; !ok {
d.add("item_action", k, "removed", upgradeAuto, "")
}
}
for _, k := range upgradeSortedKeys(newAct) {
o, ok := oldAct[k]
switch {
case !ok:
d.add("item_action", k, "added", upgradeReview, "")
case !reflect.DeepEqual(o, newAct[k]):
d.add("item_action", k, "changed", upgradeReview, "")
}
}
// Companion collections, by key.
oldColl, newColl := map[string]appmanifest.Collection{}, map[string]appmanifest.Collection{}
for _, c := range old.CompanionPack.Collections {
oldColl[c.Key] = c
}
for i, c := range next.CompanionPack.Collections {
newColl[c.Key] = c
o, ok := oldColl[c.Key]
if !ok {
d.add("collection", c.Key, "added", upgradeReview, fmt.Sprintf("a new companion collection %q", c.Slug))
continue
}
path := fmt.Sprintf("companion_pack.collections[%d]", i)
if o.Slug != c.Slug {
return nil, refuseUpgrade(path+".slug", "companion collection %q changed its slug (%q to %q); the slug is how the app addresses it", c.Key, o.Slug, c.Slug)
}
if o.Name != c.Name {
d.add("collection", c.Key, "changed", upgradeReview, fmt.Sprintf("name %q to %q", o.Name, c.Name))
}
adds, err := additiveFields(o.Parsed, c.Parsed, path+".schema", c.Key)
if err != nil {
return nil, err
}
for _, f := range adds {
d.SchemaAdds = append(d.SchemaAdds, upgradeSchemaAdd{CollectionKey: c.Key, CollectionSlug: c.Slug, Field: f})
d.add("field", c.Key+"."+f.Key, "added", upgradeReview, fmt.Sprintf("an optional %s field %q", f.Type, f.Key))
}
}
for _, k := range upgradeSortedKeys(oldColl) {
if _, ok := newColl[k]; !ok {
d.Released = append(d.Released, oldColl[k].Slug)
d.add("collection", k, "released", upgradeAuto, fmt.Sprintf("%q is released to the workspace (data kept); the app can no longer read or write it", oldColl[k].Slug))
}
}
// Artifacts, by key.
newArt := map[string]appPreviewArtifact{}
for _, a := range fresh.Artifacts {
newArt[a.Key] = a
}
oldArt := map[string]bool{}
for _, a := range old.CompanionPack.Artifacts {
oldArt[a.Key] = true
}
for _, k := range upgradeSortedKeys(oldArt) {
if _, ok := newArt[k]; !ok {
d.add("artifact", k, "removed", upgradeAuto, "the installed item is kept")
}
}
for _, a := range fresh.Artifacts {
was, had := oldDigests[a.Key]
switch {
case !had || !oldArt[a.Key]:
d.ChangedArtifacts[a.Key] = true
d.add("artifact", a.Key, "added", upgradeReview, "lands as a new draft")
case was.Raw != a.RawSHA256:
// Changed is decided by the RAW digest. The normalized digest of
// an unchanged artifact moves on every upgrade (its fresh
// normalization de-collides against the very item the install
// created, e.g. invocation_slug "ship" becomes "ship-2"), and an
// unchanged artifact stores nothing, so there is nothing of it to
// review. A changed one lands as a new draft whose normalized
// form is the reviewed one (TASK-3397 U8b2, flagged to the lead).
d.ChangedArtifacts[a.Key] = true
d.add("artifact", a.Key, "changed", upgradeReview, "lands as a new draft; the installed item is not changed")
}
}
return d, nil
}
// additiveFields returns the fields next adds to old, refusing every other
// schema change (lead ruling 2, day 86). An added field must be optional
// with no default, not computed, and declare no terminal or abandoned
// options: each of those would change existing items, which hold no value
// for it (a backfill, or a reclassification of their done state).
func additiveFields(old, next models.CollectionSchema, path, collKey string) ([]models.FieldDef, error) {
oldByKey := map[string]models.FieldDef{}
for _, f := range old.Fields {
oldByKey[f.Key] = f
}
newByKey := map[string]bool{}
var adds []models.FieldDef
for _, f := range next.Fields {
newByKey[f.Key] = true
o, ok := oldByKey[f.Key]
if ok {
if !reflect.DeepEqual(o, f) {
return nil, refuseUpgrade(path, "companion collection %q changes its existing field %q; v1 upgrades may only ADD optional fields (reinstall to change a field)", collKey, f.Key)
}
continue
}
switch {
case f.Required:
return nil, refuseUpgrade(path, "companion collection %q adds a required field %q; existing items have no value for it", collKey, f.Key)
case f.Default != nil:
return nil, refuseUpgrade(path, "companion collection %q adds field %q with a default; existing items would need a backfill", collKey, f.Key)
case f.Computed:
return nil, refuseUpgrade(path, "companion collection %q adds a computed field %q; existing items would need a backfill", collKey, f.Key)
case len(f.TerminalOptions) > 0 || len(f.AbandonedOptions) > 0:
return nil, refuseUpgrade(path, "companion collection %q adds field %q with terminal or abandoned options, which would reclassify existing items", collKey, f.Key)
}
adds = append(adds, f)
}
for _, f := range old.Fields {
if !newByKey[f.Key] {
return nil, refuseUpgrade(path, "companion collection %q removes its field %q; v1 upgrades may only ADD optional fields", collKey, f.Key)
}
}
// Anything in the schema besides fields must be unchanged.
oc, nc := old, next
oc.Fields, nc.Fields = nil, nil
if !reflect.DeepEqual(oc, nc) {
return nil, refuseUpgrade(path, "companion collection %q changes its schema beyond adding optional fields", collKey)
}
return adds, nil
}
func upgradeSortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func sameStringSet(a, b []string) bool {
return subsetOf(a, b) && subsetOf(b, a)
}
func subsetOf(a, b []string) bool {
in := map[string]bool{}
for _, x := range b {
in[x] = true
}
for _, x := range a {
if !in[x] {
return false
}
}
return true
}
func jsonEqual(a, b json.RawMessage) bool {
// Absent and null are the same declaration: the stored manifest is
// re-marshalled from the struct, which writes an unset RawMessage as
// null, while a freshly parsed manifest leaves it empty.
isNone := func(r json.RawMessage) bool {
t := strings.TrimSpace(string(r))
return t == "" || t == "null"
}
if isNone(a) && isNone(b) {
return true
}
var x, y any
if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil {
return string(a) == string(b)
}
return reflect.DeepEqual(x, y)
}