mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
b0dc7d51e5
* 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)
415 lines
15 KiB
Go
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
|
|
}
|