Files
pad/internal/server/app_api_write.go
T
xarmian b0dc7d51e5 feat(apps): the app API admits a person's delegated token (TASK-3399, SPEC-6 U5b-2) (#1782)
* feat(apps): the app API admits a person's delegated token (fresh person, membership and credential checks, the lesser of consented and offered access), attributes their writes to them, and opens delegated sign-in (TASK-3399, U5b-2)

* test(apps): the app API for a person: identity, the lesser access, per-request person checks, the person's visibility, and the fresh-context re-check; the delegated author is the person (TASK-3399, U5b-2)

* test(apps): a delegated upload re-admits the person before its row, and records them as the uploader (TASK-3399, U5b-2)

* fix(apps): U5b-2 codex r1: every app write re-admits its actor after reading its body and before its write (one helper the upload shares); a move during the body refuses the write (TASK-3399, U5b-2)

* fix(apps): U5b-2 codex r2: the app item list windows over rows the actor may see, filtered in SQL like the human list, so has_more and next_offset count nothing hidden (TASK-3399, U5b-2)

* fix(apps): U5b-2 codex r3: re-admission's final step re-checks the workspace and the actor's role, not only the credential (TASK-3399, U5b-2)

* test(apps): a delegated app write's event names the install (U10d) and credits the person, never the bot (TASK-3399, U5b-2 x U10d)
2026-10-04 22:57:52 -04:00

415 lines
15 KiB
Go

package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"github.com/go-chi/chi/v5"
"github.com/PerpetualSoftware/pad/internal/appstore"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/PerpetualSoftware/pad/internal/watchevents"
)
// The app API's write handlers (SPEC-6 §4, U6b). Each is thin: a fixed
// request DTO with unknown fields refused; the target resolved under the
// ceiling; the write rule; the subject's edit check; ONE appstore call, which
// runs in a FencedTx (epoch fence, companion check, created_via_app, etag,
// caps); then the side effects the parity table moves into shared functions
// (SSE, watch notifications); then a fixed response DTO.
//
// Writes are not re-validated after commit (lead ruling R2): see the route
// wrapper in app_api.go.
// Request DTOs: the only fields that exist.
type AppItemCreateRequest struct {
Title string `json:"title"`
Content string `json:"content"`
Fields map[string]any `json:"fields"`
}
type AppItemUpdateRequest struct {
FieldsPatch map[string]any `json:"fields_patch"`
ExpectedETag string `json:"expected_etag"`
}
type AppCommentCreateRequest struct {
Body string `json:"body"`
ParentCommentID string `json:"parent_comment_id"`
}
type AppCommentUpdateRequest struct {
Body string `json:"body"`
}
// decodeAppJSON decodes a request DTO through the ordinary body rules (size
// cap, NUL refusal, repeated members) and refuses any key the DTO does not
// name, so nothing a DTO does not name can be written.
func decodeAppJSON(r *http.Request, v any) error {
raw, err := readBodyForDecode(r, defaultJSONBodyLimit)
if err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
if err := decodeJSONBytes(raw, v); err != nil {
return err
}
var keys map[string]json.RawMessage
if err := json.NewDecoder(bytes.NewReader(raw)).Decode(&keys); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
allowed := map[string]bool{}
tp := reflect.TypeOf(v).Elem()
for i := 0; i < tp.NumField(); i++ {
allowed[strings.Split(tp.Field(i).Tag.Get("json"), ",")[0]] = true
}
for k := range keys {
if !allowed[k] {
return fmt.Errorf("unknown field %q", k)
}
}
return nil
}
// appActor is the FencedActor for this request: the bot as an agent for a
// service token (§4: the actor is decided by token kind; X-Pad-Agent is
// ignored). A delegated person (TASK-3399) will be "user" with their own id.
func appActor(ac *appContext) store.FencedActor {
if ac.AuthKind == "delegated" {
// A person acting through the app (TASK-3399, lead ruling R3): the
// write is theirs, with via_app = the install; never the bot's.
return store.FencedActor{Kind: "user", UserID: ac.Actor.ID}
}
return store.FencedActor{Kind: "agent", UserID: ac.Actor.ID, AgentName: ac.Actor.Name}
}
// appActorKind is the actor kind an app write's events carry: "user" for a
// person acting through the app (a delegated token), "agent" for the bot.
// X-Pad-Agent never decides it (DOC-3371 §4 Attribution).
func appActorKind(ac *appContext) string {
if ac.AuthKind == "delegated" {
return "user"
}
return "agent"
}
// appCommentAuthorKind is the author_kind of a comment an app write makes,
// as the comment listing derives it: "app" for the bot, "user" for a person.
func appCommentAuthorKind(ac *appContext) string {
if ac.AuthKind == "delegated" {
return "user"
}
return "app"
}
// writeAppStoreError maps an appstore refusal to its response.
func writeAppStoreError(w http.ResponseWriter, err error) {
if writeAppAttachmentError(w, err) {
return
}
switch {
case appstore.IsInputError(err):
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
case errors.Is(err, store.ErrFenceStale), errors.Is(err, store.ErrFenceClosed):
writeAppUnauthorized(w)
case errors.Is(err, store.ErrNotCompanion):
writeError(w, http.StatusForbidden, "forbidden", "This app may not write here")
case errors.Is(err, store.ErrAppNotAppItem):
writeError(w, http.StatusForbidden, "forbidden", "This app did not create this item")
case errors.Is(err, store.ErrAppETagMismatch):
writeError(w, http.StatusPreconditionFailed, "etag_mismatch", "The item changed; fetch it again")
case errors.Is(err, store.ErrAppItemLimit):
// No counts: the human renderer's totals are workspace-wide.
writeError(w, http.StatusForbidden, "item_limit_reached", "The workspace has reached its item limit")
case errors.Is(err, store.ErrAppCommentNotFound):
writeAppNotFound(w, "Comment")
case errors.Is(err, store.ErrAppNotCommentAuthor):
writeError(w, http.StatusForbidden, "forbidden", "Only the comment's author may change it")
case errors.Is(err, store.ErrCommentDeleted):
writeError(w, http.StatusConflict, "comment_deleted", "The comment was deleted")
case errors.Is(err, store.ErrAppAttachmentUnavailable):
writeError(w, http.StatusUnprocessableEntity, "attachment_unavailable", "An attachment reference is not available")
case errors.Is(err, store.ErrAppCrossItemThread):
writeError(w, http.StatusConflict, "cross_item_thread", "The comment's thread leaves this item")
default:
writeInternalError(w, err)
}
}
func (s *Server) appCreateItem(w http.ResponseWriter, r *http.Request) {
ac := appContextFrom(r)
c, ok := s.appVisibleCollection(w, r, chi.URLParam(r, "collSlug"))
if !ok {
return
}
if !s.requireEditPermission(w, r, ac.WorkspaceID, "", c.ID) {
return
}
var in AppItemCreateRequest
if err := decodeAppJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if !s.appReadmitBeforeWrite(w, r, "", c.ID) {
return
}
as, err := s.appStore()
if err != nil {
writeInternalError(w, err)
return
}
wr, err := as.CreateItemWrite(r.Context(), ac.appFenceSpec(), c.ID, appstore.AppItemCreate{Title: in.Title, Content: in.Content, Fields: in.Fields}, appActor(ac))
if err != nil {
writeAppStoreError(w, err)
return
}
item := wr.Item
s.publishItemEventWithName(sseItemCreated, ac.WorkspaceID, item.ID, item.Title, wr.View.CollectionSlug, appActorKind(ac), wr.ActorDisplay, "app", item.Seq)
writeAppJSON(w, http.StatusCreated, appWrittenItemDTO(wr))
}
// appWrittenItemDTO is a write's response, built only from what the write's
// own transaction read (appstore.ItemWrite). Nothing here touches the
// database: the write has committed, and a later read could fail and report
// a committed write as failed (inviting a duplicate retry), or describe a
// state the write did not produce, such as a schema changed since the
// handler resolved the collection.
func appWrittenItemDTO(wr *appstore.ItemWrite) AppItem {
it := wr.Item
c := &models.Collection{Slug: wr.View.CollectionSlug, Schema: wr.View.SchemaJSON}
return AppItem{
ID: it.ID, Collection: c.Slug, Title: it.Title, Content: it.Content,
Fields: appFieldProjection(it.Fields, c), ETag: wr.ETag,
CreatedAt: it.CreatedAt, UpdatedAt: it.UpdatedAt,
CreatedByDisplay: wr.View.CreatorDisplay, ViaApp: wr.View.ViaApp,
}
}
func (s *Server) appUpdateItem(w http.ResponseWriter, r *http.Request) {
ac := appContextFrom(r)
before, _, ok := s.appVisibleItem(w, r)
if !ok {
return
}
if !s.requireEditPermission(w, r, ac.WorkspaceID, before.ID, before.CollectionID) {
return
}
var in AppItemUpdateRequest
if err := decodeAppJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if !s.appReadmitBeforeWrite(w, r, before.ID, before.CollectionID) {
return
}
as, err := s.appStore()
if err != nil {
writeInternalError(w, err)
return
}
wr, err := as.UpdateItemWrite(r.Context(), ac.appFenceSpec(), before.ID, appstore.AppItemUpdate{FieldsPatch: in.FieldsPatch, ExpectedETag: in.ExpectedETag}, appActor(ac))
if err != nil {
writeAppStoreError(w, err)
return
}
after := wr.Item
s.publishItemEventWithName(sseItemUpdated, ac.WorkspaceID, after.ID, after.Title, wr.View.CollectionSlug, appActorKind(ac), wr.ActorDisplay, "app", after.Seq)
// Watchers hear about an app's status change as about anyone's (lead
// ruling R1). The signal is the fenced transaction's own, never a
// comparison with this handler's earlier read: a status another writer
// changed between that read and the commit is not this write's.
if after.LastMutation != nil && after.LastMutation.StatusChanged {
s.publishWatchNotifications(ac.WorkspaceID, after, appActorKind(ac), wr.ActorDisplay)
}
writeAppJSON(w, http.StatusOK, appWrittenItemDTO(wr))
}
// appCommentDTO is one comment's DTO, written by this request's actor.
func appCommentDTO(c *models.Comment, authorKind string) AppComment {
dto := AppComment{ID: c.ID, ItemID: c.ItemID, Body: c.Body, AuthorDisplay: c.Author, AuthorKind: authorKind,
CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, Edited: c.IsEdited(), Deleted: c.Deleted}
if c.ParentID != "" {
p := c.ParentID
dto.ParentCommentID = &p
}
return dto
}
func (s *Server) appCreateComment(w http.ResponseWriter, r *http.Request) {
ac := appContextFrom(r)
item, _, ok := s.appVisibleItem(w, r)
if !ok {
return
}
if !s.requireEditPermission(w, r, ac.WorkspaceID, item.ID, item.CollectionID) {
return
}
var in AppCommentCreateRequest
if err := decodeAppJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if !s.appReadmitBeforeWrite(w, r, item.ID, item.CollectionID) {
return
}
as, err := s.appStore()
if err != nil {
writeInternalError(w, err)
return
}
// The author is server-set: the app's bot display name (lead ruling R3),
// read in the write's transaction (Author left empty). What the write
// publishes is the item as that transaction found it: a human may have
// moved it to another companion since this handler's read, and a
// notification naming the old collection would reach that collection's
// watchers.
cw, err := as.CreateCommentWrite(r.Context(), ac.appFenceSpec(), item.ID,
appstore.AppCommentCreate{Body: in.Body, ParentID: in.ParentCommentID}, appActor(ac))
if err != nil {
writeAppStoreError(w, err)
return
}
comment, at := cw.Comment, cw.Item
s.publishCommentEvent(sseCommentCreated, ac.WorkspaceID, at.ID, comment.ID, at.Title, at.CollectionSlug, appActorKind(ac), "app")
s.publishWatchNotification(watchevents.Notification{
WorkspaceID: ac.WorkspaceID,
ItemID: at.ID,
CollectionID: at.CollectionID,
ItemRef: at.Ref,
Kind: watchevents.KindComment,
Actor: appActorKind(ac),
ActorName: cw.ActorDisplay,
Summary: truncateForSummary(comment.Body, 120),
})
writeAppJSON(w, http.StatusCreated, appCommentDTO(comment, appCommentAuthorKind(ac)))
}
// appCommentOnItem resolves the comment for a PATCH or DELETE: the item
// first, then the comment, which must be on THAT item, else the same 404 a
// missing comment gets.
func (s *Server) appCommentOnItem(w http.ResponseWriter, r *http.Request) (*models.Item, *models.Collection, *models.Comment, bool) {
ac := appContextFrom(r)
item, c, ok := s.appVisibleItem(w, r)
if !ok {
return nil, nil, nil, false
}
comment, err := s.store.GetComment(chi.URLParam(r, "commentID"))
if err != nil {
writeInternalError(w, err)
return nil, nil, nil, false
}
// First, ahead of authorship and every other refusal (DOC-3371 ruling 6).
// Second layer: FencedTx.lockOwnComment(itemID, commentID) binds the
// comment to the item again inside the write, so dropping this check
// still answers 404 (U6b mutant W3 survives for that reason). This one is
// the layer that makes a foreign comment's 404 identical to a missing
// one's before any other lookup runs.
if comment == nil || comment.ItemID != item.ID {
writeAppNotFound(w, "Comment")
return nil, nil, nil, false
}
if !s.requireEditPermission(w, r, ac.WorkspaceID, item.ID, item.CollectionID) {
return nil, nil, nil, false
}
return item, c, comment, true
}
func (s *Server) appUpdateComment(w http.ResponseWriter, r *http.Request) {
ac := appContextFrom(r)
item, _, comment, ok := s.appCommentOnItem(w, r)
if !ok {
return
}
var in AppCommentUpdateRequest
if err := decodeAppJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if !s.appReadmitBeforeWrite(w, r, item.ID, item.CollectionID) {
return
}
as, err := s.appStore()
if err != nil {
writeInternalError(w, err)
return
}
// Author only, no admin bypass: the appstore checks user AND install.
cw, err := as.UpdateCommentWrite(r.Context(), ac.appFenceSpec(), item.ID, comment.ID, in.Body, appActor(ac))
if err != nil {
writeAppStoreError(w, err)
return
}
at := cw.Item
s.publishCommentEvent(sseCommentUpdated, ac.WorkspaceID, at.ID, cw.Comment.ID, at.Title, at.CollectionSlug, appActorKind(ac), "app")
writeAppJSON(w, http.StatusOK, appCommentDTO(cw.Comment, appCommentAuthorKind(ac)))
}
func (s *Server) appDeleteComment(w http.ResponseWriter, r *http.Request) {
ac := appContextFrom(r)
item, _, comment, ok := s.appCommentOnItem(w, r)
if !ok {
return
}
if !s.appReadmitBeforeWrite(w, r, item.ID, item.CollectionID) {
return
}
as, err := s.appStore()
if err != nil {
writeInternalError(w, err)
return
}
// Author only; refused if the comment's ancestor chain leaves the item.
// The human path publishes no SSE on delete, and neither does this one.
if err := as.DeleteComment(r.Context(), ac.appFenceSpec(), item.ID, comment.ID, appActor(ac)); err != nil {
writeAppStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// appReadmitBeforeWrite re-admits a write after its body is read and before
// its write, and reports false after answering (codex U5b-2 r1): a body can
// arrive slowly, and the person a delegated token acts for can lose access
// meanwhile. A full re-admission (the credential, the install, the person,
// their membership and role, and every re-check the handler registered,
// replayed under the fresh context), then the collection is still one the
// app may write and the actor may still edit it. The fenced transaction then
// checks the install and the companion again; what remains is the gap
// between this check and that transaction, the one an upload has.
func (s *Server) appReadmitBeforeWrite(w http.ResponseWriter, r *http.Request, itemID, collectionID string) bool {
ac := appContextFrom(r)
if err := s.appRevalidate(r); err != nil {
switch {
case errors.Is(err, errAppAdmitInternal):
writeInternalError(w, err)
case errors.Is(err, errAppCredential):
writeAppUnauthorized(w)
default:
writeError(w, http.StatusForbidden, "forbidden", "This app may no longer write here")
}
return false
}
if !appWriteAllows(r, collectionID) {
writeError(w, http.StatusForbidden, "forbidden", "This app may no longer write here")
return false
}
ok, err := s.canEditInCollection(r, ac.WorkspaceID, itemID, collectionID)
if err != nil {
writeInternalError(w, err)
return false
}
if !ok {
writeError(w, http.StatusForbidden, "forbidden", "This app may no longer write here")
return false
}
if s.appAfterWriteReadmit != nil {
s.appAfterWriteReadmit()
}
return true
}