Files
xarmian 5e2accd179 feat(apps): app write routes: items create/update, comments create/update/delete, the write rule, SSE and watch parity (TASK-3401, SPEC-6 U6b) (#1773)
* docs(store): the per-call users read in the bot ceiling is accepted; how to remove it if profiled (TASK-3401)

* feat(server): app write routes: items create/update, comments create/update/delete, the write rule ahead of the editor fast path, SSE and watch parity (TASK-3401, U6b)

* test(server): app write routes: census, read-token refusal, write rule, refusals, disable race, SSE/watch parity (TASK-3401, U6b)

* test(server): the write-rule test gives the bot a role and a companion control, so only the rule can refuse (TASK-3401, U6b)

* fix(apps): an app write's status notification comes from its own transaction, never the handler's earlier read (TASK-3401, U6b codex r1 P2)

* fix(apps): an app item write's response is built from its own transaction: slug, schema, via_app, creator and ETag read before commit, nothing read after (TASK-3401, U6b codex r2 P2 x2)

* fix(apps): app comment writes publish the item and actor display their own transaction read, not the handler's earlier read (TASK-3401, U6b codex r3 P1+P3)

* docs(server): name the second layer behind the app comment same-item check (TASK-3401, U6b)
2026-10-04 12:22:00 -04:00

317 lines
10 KiB
Go

package appstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/PerpetualSoftware/pad/internal/items"
"github.com/PerpetualSoftware/pad/internal/links"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// AppItemCreate is the only input an app item create takes (DOC-3371 §4).
// There is no parent, plan, tags, assignee, role, github_pr or convention.
type AppItemCreate struct {
Title string
Content string
Fields map[string]any
}
// AppItemUpdate is the only input an app item update takes: a scalar fields
// patch and the etag the app last read. No content, title or relation key.
type AppItemUpdate struct {
FieldsPatch map[string]any
ExpectedETag string
}
// InputError is a refusal of the request itself (400): it depends only on
// what the app sent and the companion schema, never on anything the app
// cannot see.
type InputError struct{ Reason string }
func (e *InputError) Error() string { return "app write refused: " + e.Reason }
func refuse(format string, args ...any) error {
return &InputError{Reason: fmt.Sprintf(format, args...)}
}
// maxSlugAttempts bounds the retry on a random-suffix slug collision, which
// fails the whole transaction (on Postgres a failed statement aborts it).
const maxSlugAttempts = 3
// ItemWrite is a committed app item write and the view its response is
// built from, read in the same transaction (store.FencedTx.ItemView).
type ItemWrite struct {
Item *models.Item
View store.FencedItemView
ETag string
// ActorDisplay is the acting identity's name, read in the transaction.
ActorDisplay string
}
// CreateItem creates an item in a companion collection, in one FencedTx.
func (a *Store) CreateItem(ctx context.Context, spec store.FenceSpec, collectionID string, in AppItemCreate, actor store.FencedActor) (*models.Item, error) {
w, err := a.CreateItemWrite(ctx, spec, collectionID, in, actor)
if err != nil {
return nil, err
}
return w.Item, nil
}
// CreateItemWrite is CreateItem with the response view.
func (a *Store) CreateItemWrite(ctx context.Context, spec store.FenceSpec, collectionID string, in AppItemCreate, actor store.FencedActor) (*ItemWrite, error) {
// The response carries an ETag, so a key that cannot make one refuses the
// write before it happens rather than after.
if err := a.etagKeyUsable(); err != nil {
return nil, err
}
title := models.NormalizeItemTitle(in.Title)
if msg := models.ValidateItemTitle(title); msg != "" {
return nil, refuse("%s", msg)
}
if err := refuseReferences(in.Content); err != nil {
return nil, err
}
if err := refuseWorkspaceLinks(in.Content); err != nil {
return nil, err
}
fields := in.Fields
if fields == nil {
fields = map[string]any{}
}
if err := refuseReferencesInFields(fields); err != nil {
return nil, err
}
var lastErr error
for attempt := 0; attempt < maxSlugAttempts; attempt++ {
item, err := a.createItemOnce(ctx, spec, collectionID, title, in.Content, fields, actor)
if err == nil || !store.IsItemSlugConflict(err) {
return item, err
}
lastErr = err
}
return nil, lastErr
}
func (a *Store) createItemOnce(ctx context.Context, spec store.FenceSpec, collectionID, title, content string, fields map[string]any, actor store.FencedActor) (*ItemWrite, error) {
ftx, err := a.s.BeginFenced(ctx, spec)
if err != nil {
return nil, err
}
defer func() { _ = ftx.Rollback() }()
schema, err := companionSchema(ftx, collectionID)
if err != nil {
return nil, err
}
// A fresh copy per attempt: validation applies defaults in place.
working := make(map[string]any, len(fields))
for k, v := range fields {
working[k] = v
}
if err := checkFieldKeys(working, schema); err != nil {
return nil, err
}
working = items.CoerceFields(working, schema)
// Validation applies schema defaults in place, AFTER the key checks
// above, so it runs against a schema whose forbidden fields carry no
// default: a default may not put a relation, computed or uniqueness-scoped
// value on an app item. A required field of those kinds then cannot be
// satisfied by an app, which is the refusal it should get.
if err := items.ValidateFields(working, appWritableSchema(schema)); err != nil {
return nil, refuse("%v", err)
}
// And a default is still content: no pad-attachment: token may arrive
// through one either.
if err := refuseReferencesInFields(working); err != nil {
return nil, err
}
blob, err := json.Marshal(working)
if err != nil {
return nil, fmt.Errorf("app create: encode fields: %w", err)
}
item, err := ftx.CreateItem(store.FencedItemCreate{
CollectionID: collectionID, Title: title, Content: content, Fields: string(blob), Actor: actor,
PlanLimit: a.opts.PlanLimit,
})
if err != nil {
return nil, err
}
view, err := ftx.ItemView(item.ID)
if err != nil {
return nil, err
}
who, err := ftx.ActorDisplay(actor)
if err != nil {
return nil, err
}
if err := ftx.Commit(); err != nil {
return nil, err
}
return &ItemWrite{Item: item, View: view, ETag: store.AppItemETag(a.opts.ETagKey, spec.InstallID, item.ID, item.Seq), ActorDisplay: who}, nil
}
// UpdateItem applies a scalar fields patch to an item this install created,
// in one FencedTx.
func (a *Store) UpdateItem(ctx context.Context, spec store.FenceSpec, itemID string, in AppItemUpdate, actor store.FencedActor) (*models.Item, error) {
w, err := a.UpdateItemWrite(ctx, spec, itemID, in, actor)
if err != nil {
return nil, err
}
return w.Item, nil
}
// UpdateItemWrite is UpdateItem with the response view.
func (a *Store) UpdateItemWrite(ctx context.Context, spec store.FenceSpec, itemID string, in AppItemUpdate, actor store.FencedActor) (*ItemWrite, error) {
if len(in.FieldsPatch) == 0 {
return nil, refuse("fields_patch is required")
}
if in.ExpectedETag == "" {
return nil, refuse("expected_etag is required")
}
if err := a.etagKeyUsable(); err != nil {
return nil, err
}
if err := refuseReferencesInFields(in.FieldsPatch); err != nil {
return nil, err
}
ftx, err := a.s.BeginFenced(ctx, spec)
if err != nil {
return nil, err
}
defer func() { _ = ftx.Rollback() }()
collectionID, err := ftx.CompanionItemCollection(itemID)
if err != nil {
return nil, err
}
schema, err := companionSchema(ftx, collectionID)
if err != nil {
return nil, err
}
patch := make(map[string]any, len(in.FieldsPatch))
for k, v := range in.FieldsPatch {
patch[k] = v
}
if err := checkFieldKeys(patch, schema); err != nil {
return nil, err
}
patch = items.CoerceFields(patch, schema)
if err := items.ValidatePartialFields(patch, schema); err != nil {
return nil, refuse("%v", err)
}
item, err := ftx.UpdateItemFields(itemID, patch, in.ExpectedETag, a.opts.ETagKey, actor)
if err != nil {
return nil, err
}
view, err := ftx.ItemView(itemID)
if err != nil {
return nil, err
}
who, err := ftx.ActorDisplay(actor)
if err != nil {
return nil, err
}
if err := ftx.Commit(); err != nil {
return nil, err
}
return &ItemWrite{Item: item, View: view, ETag: store.AppItemETag(a.opts.ETagKey, spec.InstallID, item.ID, item.Seq), ActorDisplay: who}, nil
}
// appWritableSchema is schema with every default removed from the field
// kinds an app may not write.
func appWritableSchema(schema models.CollectionSchema) models.CollectionSchema {
out := schema
out.Fields = make([]models.FieldDef, len(schema.Fields))
for i, def := range schema.Fields {
if def.IsRelation() || def.IsMultiRelation() || def.Computed || def.UniqueScope != "" {
def.Default = nil
}
out.Fields[i] = def
}
return out
}
func companionSchema(ftx *store.FencedTx, collectionID string) (models.CollectionSchema, error) {
schemaJSON, _, err := ftx.CompanionCollectionSchema(collectionID)
if err != nil {
return models.CollectionSchema{}, err
}
var schema models.CollectionSchema
if schemaJSON != "" {
if err := models.UnmarshalItemFieldSchema([]byte(schemaJSON), &schema); err != nil {
return models.CollectionSchema{}, fmt.Errorf("app write: companion schema: %w", err)
}
}
return schema, nil
}
// checkFieldKeys refuses any key the companion schema does not declare, and
// any key an app may not write: a relation or multi-relation (an app update
// never touches relation edges), a computed field, a uniqueness-scoped field
// (v1 has no fenced uniqueness check), and the reserved metadata keys.
func checkFieldKeys(fields map[string]any, schema models.CollectionSchema) error {
if undeclared := items.UndeclaredFieldKeys(fields, schema); len(undeclared) > 0 {
return refuse("undeclared field(s): %s", strings.Join(undeclared, ", "))
}
if reserved := items.ReservedFieldKeysIn(fields); len(reserved) > 0 {
return refuse("reserved field(s): %s", strings.Join(reserved, ", "))
}
for _, def := range schema.Fields {
if _, set := fields[def.Key]; !set {
continue
}
switch {
case def.IsRelation() || def.IsMultiRelation():
return refuse("field %q is a relation, which an app may not write", def.Key)
case def.Computed:
return refuse("field %q is computed", def.Key)
case def.UniqueScope != "":
return refuse("field %q is uniqueness-scoped, which an app may not write in v1", def.Key)
}
}
return nil
}
// refuseReferences refuses ANY pad-attachment: token, whether or not the id
// exists, in any spelling: an unconditional refusal is not an oracle, and
// nothing can be bound to an item that does not exist yet (DOC-3371 §4).
func refuseReferences(text string) error {
if strings.Contains(strings.ToLower(text), "pad-attachment:") {
return refuse("pad-attachment: references are not accepted")
}
return nil
}
func refuseReferencesInFields(fields map[string]any) error {
b, err := json.Marshal(fields)
if err != nil {
return refuse("fields are not valid JSON values")
}
return refuseReferences(string(b))
}
// refuseWorkspaceLinks refuses a workspace-qualified wiki link, [[ws::REF]],
// even one naming this workspace: an app link names only companion items.
func refuseWorkspaceLinks(content string) error {
for _, l := range links.ExtractWikiLinks(content) {
if l.Kind == links.WikiLinkKindWorkspaceRef {
return refuse("workspace-qualified links are not accepted")
}
}
return nil
}
// IsInputError reports whether err is a refusal of the request itself.
func IsInputError(err error) bool {
var ie *InputError
return errors.As(err, &ie)
}