mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
95ae3adbd9
* fix(apps): an installed app learns its workspace from the redeem, an unscoped /me and every webhook (BUG-3416) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): redeem resolves the live workspace before it spends the code (codex r1 on BUG-3416) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): redeem holds the workspace row FOR SHARE, so a soft delete in flight lands before it or after its commit (codex r2 on BUG-3416) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): redeem locks the workspace before the install, the order account deletion and purge take (codex r3 on BUG-3416) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(apps): redeem's workspace read takes no lock; a database failure is a fault, not a bad code (lead ruling, codex r4 on BUG-3416) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
495 lines
16 KiB
Go
495 lines
16 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. The window is over
|
|
// rows the actor may see, filtered in SQL as the human list filters a
|
|
// restricted member's or guest's item grants (guestResourceFilter): a
|
|
// person acting through the app (TASK-3399) may hold item grants only,
|
|
// and a window over hidden rows would let has_more and next_offset count
|
|
// them (codex U5b-2 r2). The per-item check below stays, as a second
|
|
// layer.
|
|
params := models.ItemListParams{ScopeCollectionID: c.ID, CollectionIDs: []string{c.ID}, Limit: limit + 1, Offset: offset}
|
|
fullCollIDs, grantedItemIDs, err := s.guestResourceFilter(r, ac.WorkspaceID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if len(grantedItemIDs) > 0 {
|
|
params.CollectionIDs = fullCollIDs
|
|
if params.CollectionIDs == nil {
|
|
params.CollectionIDs = []string{}
|
|
}
|
|
params.ItemIDs = grantedItemIDs
|
|
}
|
|
items, err := s.store.ListItems(ac.WorkspaceID, params)
|
|
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})
|
|
}
|
|
|
|
// AppInstallMe is the unscoped GET /api/app/v1/me (BUG-3416): the token's
|
|
// binding. UserID and DisplayName are the actor: the install's bot for a
|
|
// service token, the person for a delegated one.
|
|
type AppInstallMe struct {
|
|
InstallID string `json:"install_id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
WorkspaceSlug string `json:"workspace_slug"`
|
|
AuthKind string `json:"auth_kind"`
|
|
Access string `json:"access"`
|
|
UserID string `json:"user_id"`
|
|
DisplayName string `json:"display_name"`
|
|
IsApp bool `json:"is_app"`
|
|
}
|
|
|
|
func (s *Server) appInstallMe(w http.ResponseWriter, r *http.Request) {
|
|
ac := appContextFrom(r)
|
|
writeAppJSON(w, http.StatusOK, AppInstallMe{
|
|
InstallID: ac.InstallID, WorkspaceID: ac.WorkspaceID, WorkspaceSlug: ac.WorkspaceSlug,
|
|
AuthKind: ac.AuthKind, Access: ac.Access,
|
|
UserID: ac.Actor.ID, DisplayName: ac.Actor.Name, IsApp: ac.Actor.IsApp(),
|
|
})
|
|
}
|
|
|
|
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()})
|
|
}
|