Files
pad/internal/server/app_api_read.go
T
xarmian 37066f97be feat(apps): app attachment routes: item-scoped upload, metadata, and a streamed download re-admitted before its first byte (TASK-3401, SPEC-6 U6c) (#1776)
* feat(apps): app attachment routes: item-scoped upload, metadata and a streamed download re-admitted before its first byte (TASK-3401, SPEC-6 U6c)

* test(server): an app attachment read refuses a subject whose membership no longer reaches the item (TASK-3401, U6c)

* test(server): U6c tests reach the item re-check, the upload's edit right and the download's type decision (TASK-3401, U6c)

* test(server): a deletion before the first byte reaches the attachment re-check (TASK-3401, U6c)

* fix(apps): U6c codex r1: authorize before storage, a transport write deadline, the credential last in re-admission, and a live companion check in the fence (TASK-3401, U6c)

* fix(apps): U6c codex r2: a truncated upload is 400, a re-admission fault is 500 not 401, HEAD on the download, and unknown variants refused (TASK-3401, U6c)

* fix(apps): U6c codex r3: the upload re-admits the subject before its row (StageUpload/Insert, no store callback), and variant is null on originals (TASK-3401, U6c)

* fix(apps): U6c codex r4: a token-state or settings read failure during admission is a fault (500), not a denial (TASK-3401, U6c)

* fix(apps): U6c codex r5: a credential that dies mid-upload answers 401 invalid_token, a permission change 403 (TASK-3401, U6c)

* fix(apps): U6c codex r6: the served variant is re-checked at the gate, and a settings read fault at the first gate is a 500 (TASK-3401, U6c)

* test(server): a viewer's upload is refused before its body is read (TASK-3401, U6c)

* test(server): account for the app upload's raw body reader in the body-reader census (TASK-3401, U6c)
2026-10-04 15:14:27 -04:00

458 lines
14 KiB
Go

package server
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/PerpetualSoftware/pad/internal/models"
)
// The app API's read handlers and response DTOs (SPEC-6 §4, U6a). Each
// handler authorizes through the shared helpers under the ceiling and encodes
// a FIXED DTO: anything a DTO does not name is never sent, whatever the store
// row carries. The DTO census test pins every key, nested ones included.
// AppSchemaField is one field of AppCollection's schema projection: declared
// non-relation fields only. Never a target collection; a default only for a
// scalar, non-reference type.
type AppSchemaField struct {
Key string `json:"key"`
Type string `json:"type"`
Label string `json:"label"`
Options []string `json:"options,omitempty"`
Required bool `json:"required"`
Default any `json:"default,omitempty"`
}
// AppCollectionSchema is the projection of a collection's schema.
type AppCollectionSchema struct {
Fields []AppSchemaField `json:"fields"`
}
// AppCollection never carries settings, traits, counts, relation fields or
// any target collection slug.
type AppCollection struct {
Slug string `json:"slug"`
Name string `json:"name"`
Icon string `json:"icon"`
IsSystem bool `json:"is_system"`
Schema AppCollectionSchema `json:"schema"`
}
// AppItem never carries slug, ref, seq, parent, lease, decisions, moved_to,
// relation_targets, assignee, role, github_pr, implementation notes or the
// decision log.
type AppItem struct {
ID string `json:"id"`
Collection string `json:"collection"`
Title string `json:"title"`
Content string `json:"content"`
Fields map[string]any `json:"fields"`
ETag string `json:"etag"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedByDisplay string `json:"created_by_display"`
ViaApp string `json:"via_app"`
}
// AppComment never carries reactions or user_id.
type AppComment struct {
ID string `json:"id"`
ItemID string `json:"item_id"`
Body string `json:"body"`
AuthorDisplay string `json:"author_display"`
AuthorKind string `json:"author_kind"`
ParentCommentID *string `json:"parent_comment_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Edited bool `json:"edited"`
Deleted bool `json:"deleted"`
}
// AppMe never carries the email, platform role, TOTP or billing.
type AppMe struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
IsApp bool `json:"is_app"`
}
// scalarFieldTypes are the types whose default an app may see.
var scalarFieldTypes = map[string]bool{"text": true, "url": true, "number": true, "checkbox": true, "date": true, "select": true, "multi_select": true}
func appSchemaProjection(c *models.Collection) AppCollectionSchema {
out := AppCollectionSchema{Fields: []AppSchemaField{}}
var schema models.CollectionSchema
if c.Schema == "" || models.UnmarshalItemFieldSchema([]byte(c.Schema), &schema) != nil {
return out
}
for _, f := range schema.Fields {
if f.Type == "relation" || f.IsMultiRelation() {
continue
}
af := AppSchemaField{Key: f.Key, Type: f.Type, Label: f.Label, Options: f.Options, Required: f.Required}
if scalarFieldTypes[f.Type] {
af.Default = f.Default
}
out.Fields = append(out.Fields, af)
}
return out
}
// appFieldProjection keeps only the item's values for the collection's
// declared non-relation keys.
func appFieldProjection(fieldsJSON string, c *models.Collection) map[string]any {
out := map[string]any{}
allowed := map[string]bool{}
for _, f := range appSchemaProjection(c).Fields {
allowed[f.Key] = true
}
dec := json.NewDecoder(bytes.NewReader([]byte(fieldsJSON)))
dec.UseNumber()
var raw map[string]any
if fieldsJSON == "" || dec.Decode(&raw) != nil {
return out
}
for k, v := range raw {
if allowed[k] {
out[k] = v
}
}
return out
}
func writeAppJSON(w http.ResponseWriter, status int, v any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeAppNotFound(w http.ResponseWriter, what string) {
writeError(w, http.StatusNotFound, "not_found", what+" not found")
}
// appVisibleCollection resolves a collection by EXACT slug in the token's
// workspace, inside the ceiling and the actor's visibility, or reports false
// after answering the same 404 a missing collection gets.
func (s *Server) appVisibleCollection(w http.ResponseWriter, r *http.Request, slug string) (*models.Collection, bool) {
ac := appContextFrom(r)
c, err := s.store.GetCollectionBySlug(ac.WorkspaceID, slug)
if err != nil {
writeInternalError(w, err)
return nil, false
}
if c == nil || c.DeletedAt != nil || c.Slug != slug {
writeAppNotFound(w, "Collection")
return nil, false
}
ids, err := s.visibleCollectionIDs(r, ac.WorkspaceID)
if err != nil {
writeInternalError(w, err)
return nil, false
}
if !isCollectionVisible(c.ID, ids) || !appCeilingAllows(r, c.ID) {
writeAppNotFound(w, "Collection")
return nil, false
}
id := c.ID
appAddRecheck(r, func(r2 *http.Request) error { return s.appRecheckCollection(r2, id) })
return c, true
}
var errAppRecheck = errors.New("app authorization no longer holds")
// appRecheckCollection replays a collection's authorization against the
// store as it stands: live, in the workspace, visible to the actor, inside
// the ceiling.
func (s *Server) appRecheckCollection(r *http.Request, collectionID string) error {
if memo, ok := r.Context().Value(appRecheckMemoKey{}).(*appRecheckMemo); ok {
if err, seen := memo.collections[collectionID]; seen {
return err
}
err := s.appRecheckCollectionUncached(r, collectionID)
memo.collections[collectionID] = err
return err
}
return s.appRecheckCollectionUncached(r, collectionID)
}
func (s *Server) appRecheckCollectionUncached(r *http.Request, collectionID string) error {
ac := appContextFrom(r)
c, err := s.store.GetCollection(collectionID)
if err != nil {
return appFault(err)
}
if c == nil || c.DeletedAt != nil || c.WorkspaceID != ac.WorkspaceID {
return errAppRecheck
}
ids, err := s.visibleCollectionIDs(r, ac.WorkspaceID)
if err != nil {
return appFault(err)
}
if !isCollectionVisible(c.ID, ids) || !appCeilingAllows(r, c.ID) {
return errAppRecheck
}
return nil
}
// appRecheckItem replays an item's authorization: live, in the workspace and
// collection it was read from, inside the ceiling, visible to the actor.
func (s *Server) appRecheckItem(r *http.Request, itemID, collectionID string) error {
ac := appContextFrom(r)
it, err := s.store.GetItem(itemID)
if err != nil {
return appFault(err)
}
if it == nil || it.DeletedAt != nil || it.WorkspaceID != ac.WorkspaceID || it.CollectionID != collectionID {
return errAppRecheck
}
if !appCeilingAllows(r, it.CollectionID) {
return errAppRecheck
}
ok, err := s.checkItemVisible(ac.WorkspaceID, it, currentUser(r), workspaceRole(r), isBearerAuth(r))
if err != nil {
return appFault(err)
}
if !ok {
return errAppRecheck
}
return s.appRecheckCollection(r, collectionID)
}
func (s *Server) appCollectionDTO(c *models.Collection) AppCollection {
return AppCollection{Slug: c.Slug, Name: c.Name, Icon: c.Icon, IsSystem: c.IsSystem, Schema: appSchemaProjection(c)}
}
func (s *Server) appListCollections(w http.ResponseWriter, r *http.Request) {
ac := appContextFrom(r)
all, err := s.store.ListCollections(ac.WorkspaceID)
if err != nil {
writeInternalError(w, err)
return
}
ids, err := s.visibleCollectionIDs(r, ac.WorkspaceID)
if err != nil {
writeInternalError(w, err)
return
}
out := []AppCollection{}
for i := range all {
c := &all[i]
if c.DeletedAt != nil || !isCollectionVisible(c.ID, ids) || !appCeilingAllows(r, c.ID) {
continue
}
out = append(out, s.appCollectionDTO(c))
id := c.ID
appAddRecheck(r, func(r2 *http.Request) error { return s.appRecheckCollection(r2, id) })
}
writeAppJSON(w, http.StatusOK, map[string]any{"collections": out})
}
func (s *Server) appGetCollection(w http.ResponseWriter, r *http.Request) {
c, ok := s.appVisibleCollection(w, r, chi.URLParam(r, "collSlug"))
if !ok {
return
}
writeAppJSON(w, http.StatusOK, s.appCollectionDTO(c))
}
// appItemDTOs builds the item DTOs, etag and app metadata included.
func (s *Server) appItemDTOs(r *http.Request, items []models.Item, c *models.Collection) ([]AppItem, error) {
ac := appContextFrom(r)
as, err := s.appStore()
if err != nil {
return nil, err
}
ids := make([]string, len(items))
for i := range items {
ids[i] = items[i].ID
}
meta, err := s.store.ItemsAppReadMeta(ids)
if err != nil {
return nil, err
}
spec := ac.appFenceSpec()
out := make([]AppItem, 0, len(items))
for i := range items {
it := &items[i]
etag, err := as.ETag(spec, it)
if err != nil {
return nil, err
}
m := meta[it.ID]
out = append(out, AppItem{
ID: it.ID, Collection: c.Slug, Title: it.Title, Content: it.Content,
Fields: appFieldProjection(it.Fields, c), ETag: etag,
CreatedAt: it.CreatedAt, UpdatedAt: it.UpdatedAt,
CreatedByDisplay: m.CreatorDisplay, ViaApp: m.ViaApp,
})
}
return out, nil
}
// appListLimit bounds a list: default 50, at most 200.
func appListLimit(r *http.Request) (int, int, bool) {
limit, offset := 50, 0
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 200 {
return 0, 0, false
}
limit = n
}
if v := r.URL.Query().Get("offset"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return 0, 0, false
}
offset = n
}
return limit, offset, true
}
func (s *Server) appListItems(w http.ResponseWriter, r *http.Request) {
c, ok := s.appVisibleCollection(w, r, chi.URLParam(r, "collSlug"))
if !ok {
return
}
limit, offset, ok := appListLimit(r)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "limit must be 1-200 and offset >= 0")
return
}
ac := appContextFrom(r)
// One row past the window decides has_more exactly. Item visibility
// filters AFTER the window, so a page can come back short; paging must
// therefore follow the window (next_offset), never the page's length
// (Rook's review of #1771).
items, err := s.store.ListItems(ac.WorkspaceID, models.ItemListParams{
ScopeCollectionID: c.ID, CollectionIDs: []string{c.ID}, Limit: limit + 1, Offset: offset,
})
if err != nil {
writeInternalError(w, err)
return
}
hasMore := len(items) > limit
if hasMore {
items = items[:limit]
}
// Item-level visibility, as every human list applies it.
visible := items[:0]
for i := range items {
ok, err := s.checkItemVisible(ac.WorkspaceID, &items[i], currentUser(r), workspaceRole(r), isBearerAuth(r))
if err != nil {
writeInternalError(w, err)
return
}
if ok && items[i].CollectionID == c.ID {
visible = append(visible, items[i])
id, coll := items[i].ID, c.ID
appAddRecheck(r, func(r2 *http.Request) error { return s.appRecheckItem(r2, id, coll) })
}
}
dtos, err := s.appItemDTOs(r, visible, c)
if err != nil {
writeInternalError(w, err)
return
}
writeAppJSON(w, http.StatusOK, map[string]any{"items": dtos, "has_more": hasMore, "next_offset": offset + limit})
}
// appVisibleItem resolves an item by ID in the token's workspace, inside the
// ceiling and the actor's visibility, or answers the same 404 a missing item
// gets. Apps never address items by slug or ref.
func (s *Server) appVisibleItem(w http.ResponseWriter, r *http.Request) (*models.Item, *models.Collection, bool) {
ac := appContextFrom(r)
it, err := s.store.GetItem(chi.URLParam(r, "itemID"))
if err != nil {
writeInternalError(w, err)
return nil, nil, false
}
if it == nil || it.WorkspaceID != ac.WorkspaceID || it.DeletedAt != nil {
writeAppNotFound(w, "Item")
return nil, nil, false
}
if !s.requireItemVisible(w, r, ac.WorkspaceID, it) {
return nil, nil, false
}
c, err := s.store.GetCollection(it.CollectionID)
if err != nil {
writeInternalError(w, err)
return nil, nil, false
}
if c == nil || c.DeletedAt != nil {
writeAppNotFound(w, "Item")
return nil, nil, false
}
id, coll := it.ID, it.CollectionID
appAddRecheck(r, func(r2 *http.Request) error { return s.appRecheckItem(r2, id, coll) })
return it, c, true
}
func (s *Server) appGetItem(w http.ResponseWriter, r *http.Request) {
it, c, ok := s.appVisibleItem(w, r)
if !ok {
return
}
dtos, err := s.appItemDTOs(r, []models.Item{*it}, c)
if err != nil {
writeInternalError(w, err)
return
}
writeAppJSON(w, http.StatusOK, dtos[0])
}
func (s *Server) appListComments(w http.ResponseWriter, r *http.Request) {
it, _, ok := s.appVisibleItem(w, r)
if !ok {
return
}
comments, err := s.store.ListComments(it.ID)
if err != nil {
writeInternalError(w, err)
return
}
userIDs := []string{}
onItem := map[string]bool{}
for _, c := range comments {
onItem[c.ID] = true
if c.UserID != "" {
userIDs = append(userIDs, c.UserID)
}
}
kinds, err := s.store.UserKinds(userIDs)
if err != nil {
writeInternalError(w, err)
return
}
out := make([]AppComment, 0, len(comments))
for _, c := range comments {
kind := c.CreatedBy
if kinds[c.UserID] == models.UserKindApp {
kind = "app"
} else if kind != "agent" {
kind = "user"
}
dto := AppComment{ID: c.ID, ItemID: c.ItemID, Body: c.Body, AuthorDisplay: c.Author, AuthorKind: kind,
CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, Edited: c.IsEdited(), Deleted: c.Deleted}
// A parent that is not a comment on this same item projects to null:
// legacy rows can point across items (the FK does not prevent it).
if c.ParentID != "" && onItem[c.ParentID] {
p := c.ParentID
dto.ParentCommentID = &p
}
out = append(out, dto)
}
writeAppJSON(w, http.StatusOK, map[string]any{"comments": out})
}
func (s *Server) appMe(w http.ResponseWriter, r *http.Request) {
ac := appContextFrom(r)
writeAppJSON(w, http.StatusOK, AppMe{UserID: ac.Actor.ID, DisplayName: ac.Actor.Name, Role: workspaceRole(r), IsApp: ac.Actor.IsApp()})
}