feat(apps): app item actions and context codes (TASK-3414, SPEC-6 U11) (#1784)

* feat(apps): app item actions and context codes (TASK-3414, SPEC-6 U11) [wip]

Item actions are written as app_item_actions rows at provisioning, kept in
step by a reviewed upgrade (a changed action gets a new revision, a
removed one is retired), deleted at uninstall and backfilled at start.
A viewer's click mints a single-use, two-minute context code; the app
redeems it with its service token. Mint and redeem take the install, item
and action rows in that order (FOR SHARE on Postgres), re-check the
install, the item's current companion collection, the action's revision
and the viewer's current visibility (the human item read's rule), and
refuse with one 404 body. Redeem consumes the code first. The frozen TS
type ItemAppAction and api.items.appActions / mintAppAction ship for U9.

Migrations 125 / pg 099.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(apps): U11 mint, redeem, refusal and revocation tests; redeem in the read census; NUL baseline (TASK-3414)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(apps): invalidating mutations wait for a redeem holding the section 6 locks (TASK-3414)

Deterministic: a hook pauses the redeem with its locks held while disable,
an upgrade's action change, a move or a delete is started; each stays
blocked until the redeem commits. On Postgres, dropping the install lock
lets disable through, dropping the item lock lets move and delete through.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(apps): U11 mutant kills: epoch, deactivation, companion moves, release, admin non-member, backfill and workspace guards (TASK-3414)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(apps): redeem at the token's workspace, service tokens only; no pool reads under the context locks; normalized base URL (codex r1 on U11, TASK-3414)

Redeem moves to POST /api/app/v1/context/redeem (§6's path), outside
/workspaces/{ws}: the workspace is the token's, injected before the same
per-route stack, so no workspace-path refusal can answer before the code
is consumed. It refuses a delegated token with the same 404.

The viewer re-admission reads the user, role, membership and grants on
the mint or redeem transaction (GetUserQ, ViewerRoleQ,
UserHasGrantsInWorkspaceQ). A one-connection pool runs mint and redeem for
a full member, a restricted member and a guest without hanging; switching
any of those reads back to the pool deadlocks it.

The link-out URL is built from the validated origin, refused before a
code is written when base_url does not normalize.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(apps): a delegated redeem attempt consumes the code before it is refused (codex r2 on U11, TASK-3414)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(apps): build the context code expiry from its constant (lint, TASK-3414)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
xarmian
2026-10-05 02:29:48 -04:00
committed by GitHub
parent be54575954
commit 82c45f24cd
21 changed files with 1836 additions and 52 deletions
+2
View File
@@ -748,6 +748,8 @@ func serveCmd() *cobra.Command {
// Hooks for installs provisioned before app webhooks existed
// (TASK-3408 U10a), created held until the app's next redeem.
srv.EnsureAppWebhooks(cmd.Context())
// And their item actions (TASK-3414 U11).
srv.EnsureAppItemActions(cmd.Context())
// Typed decisions (PLAN-3114 / TASK-3117). With no provider
// configured the runner is nil: no write enqueues a job and no
+77 -43
View File
@@ -139,6 +139,15 @@ func (s *Server) appStore() (*appstore.Store, error) {
return s.appstoreState.store, s.appstoreState.err
}
// appUnscopedRoutes are mounted at the router's root, not under
// /workspaces/{ws}: the workspace is the token's, injected before the same
// per-route stack runs. U11's redeem (§6: /context/redeem) is one, so a code
// sent with any workspace in its path cannot be refused before it is
// consumed (codex r1 on U11).
var appUnscopedRoutes = []appRoute{
{"POST", "/context/redeem", "appRedeemContext", (*Server).appRedeemContext, "service", "read"},
}
// registerAppAPIRoutes mounts the app router. The router sets its own
// Content-Type (jsonContentType matches only /api/v1), and RateLimit runs
// AFTER the token step, so it keys on the actor rather than the address.
@@ -148,53 +157,58 @@ func (s *Server) registerAppAPIRoutes(r chi.Router) {
r.Use(s.requireAppsAvailable)
r.Use(s.appTokenAuth)
r.Use(s.RateLimit)
build := func(rt appRoute) http.Handler {
handler := rt.Handler
access := rt.Access
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.appBeforeHandler != nil {
s.appBeforeHandler()
}
// Writes are NOT re-validated after commit (lead ruling R2,
// U6b): a write's authority is decided inside its FencedTx
// under the epoch fence, and refusing the response AFTER a
// commit would hide a write that happened and invite a
// duplicate retry. Re-admission before send is for reads.
if access == "write" {
// A write registers its re-checks too, so it can re-admit
// itself after reading its body and before its write
// (appReadmitBeforeWrite, codex U5b-2 r1). It is never
// re-validated after commit (R2).
handler(s, w, r.WithContext(context.WithValue(r.Context(), appRecheckKey{}, &appRechecks{})))
return
}
// A stream row registers its re-checks like any read, but
// re-admits itself before its first byte (appStreamGate)
// instead of being buffered: a download is too large to
// hold, and nothing is sent before the gate passes.
if access == "stream" {
handler(s, w, r.WithContext(context.WithValue(r.Context(), appRecheckKey{}, &appRechecks{})))
return
}
// The handler writes into a buffer; nothing reaches the
// app until the grant is re-validated (codex r1 P1).
buf := newAppResponseBuffer()
r = r.WithContext(context.WithValue(r.Context(), appRecheckKey{}, &appRechecks{}))
handler(s, buf, r)
if s.appAfterHandler != nil {
s.appAfterHandler()
}
if err := s.appRevalidate(r); err != nil {
writeAppRevalidateError(w, err)
return
}
buf.flushTo(w)
})
return s.requireAppAccess(rt.Access, s.requireAppWorkspace(inner))
}
r.Route("/workspaces/{ws}", func(r chi.Router) {
for _, rt := range appRoutes {
handler := rt.Handler
access := rt.Access
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.appBeforeHandler != nil {
s.appBeforeHandler()
}
// Writes are NOT re-validated after commit (lead ruling R2,
// U6b): a write's authority is decided inside its FencedTx
// under the epoch fence, and refusing the response AFTER a
// commit would hide a write that happened and invite a
// duplicate retry. Re-admission before send is for reads.
if access == "write" {
// A write registers its re-checks too, so it can re-admit
// itself after reading its body and before its write
// (appReadmitBeforeWrite, codex U5b-2 r1). It is never
// re-validated after commit (R2).
handler(s, w, r.WithContext(context.WithValue(r.Context(), appRecheckKey{}, &appRechecks{})))
return
}
// A stream row registers its re-checks like any read, but
// re-admits itself before its first byte (appStreamGate)
// instead of being buffered: a download is too large to
// hold, and nothing is sent before the gate passes.
if access == "stream" {
handler(s, w, r.WithContext(context.WithValue(r.Context(), appRecheckKey{}, &appRechecks{})))
return
}
// The handler writes into a buffer; nothing reaches the
// app until the grant is re-validated (codex r1 P1).
buf := newAppResponseBuffer()
r = r.WithContext(context.WithValue(r.Context(), appRecheckKey{}, &appRechecks{}))
handler(s, buf, r)
if s.appAfterHandler != nil {
s.appAfterHandler()
}
if err := s.appRevalidate(r); err != nil {
writeAppRevalidateError(w, err)
return
}
buf.flushTo(w)
})
h := s.requireAppAccess(rt.Access, s.requireAppWorkspace(inner))
r.Method(rt.Method, rt.Template, h)
r.Method(rt.Method, rt.Template, build(rt))
}
})
for _, rt := range appUnscopedRoutes {
r.Method(rt.Method, rt.Template, s.appTokenWorkspace(build(rt)))
}
})
}
@@ -389,6 +403,26 @@ func (s *Server) requireAppAccess(need string, next http.Handler) http.Handler {
})
}
// appTokenWorkspace sets {ws} to the token's own workspace, for a route
// mounted outside /workspaces/{ws}, so requireAppWorkspace admits it the
// same way.
func (s *Server) appTokenWorkspace(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ac := appContextFrom(r)
if ac == nil {
writeAppUnauthorized(w)
return
}
rctx := chi.RouteContext(r.Context())
if rctx == nil {
rctx = chi.NewRouteContext()
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
rctx.URLParams.Add("ws", ac.WorkspaceID)
next.ServeHTTP(w, r)
})
}
// requireAppWorkspace is step 3 (RequireAppWorkspaceAccess). {ws} must be the
// token's workspace, by ID (lead ruling R3): a slug, another workspace, or a
// deleted one all get the workspace 404. It then sets every context key the
+256
View File
@@ -0,0 +1,256 @@
package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
"github.com/PerpetualSoftware/pad/internal/appmanifest"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// App item actions and context codes (SPEC-6 U11, DOC-3371 §6; TASK-3414).
//
// GET /api/v1/workspaces/{ws}/items/{ref}/app-actions
// the actions offered on an item, for the item pane's button;
// POST /api/v1/workspaces/{ws}/items/{ref}/app-actions/{installID}/{actionKey}
// mints a context code for the caller and answers {url};
// POST /api/app/v1/workspaces/{ws}/context/redeem {code}
// the app redeems it with its service token and receives the item.
//
// Mint and redeem refuse with ONE 404 body for every reason, so neither is
// an oracle for installs, actions, items or codes.
// writeContextRefused is the single refusal of a mint or a redeem.
func writeContextRefused(w http.ResponseWriter) {
writeError(w, http.StatusNotFound, "not_found", "Not found")
}
// appActionSpecs translates the manifest's item actions (by companion KEY)
// into the store's specs (by companion SLUG).
func appActionSpecs(m *appmanifest.Manifest) []store.AppActionSpec {
if m == nil || len(m.ItemActions) == 0 {
return nil
}
slugByKey := map[string]string{}
for _, c := range m.CompanionPack.Collections {
slugByKey[c.Key] = c.Slug
}
out := make([]store.AppActionSpec, 0, len(m.ItemActions))
for _, a := range m.ItemActions {
spec := store.AppActionSpec{Key: a.Key, Label: a.Label, Path: a.Path}
for _, k := range a.Collections {
// The manifest validator refused a key that is not a companion's.
spec.CollectionSlugs = append(spec.CollectionSlugs, slugByKey[k])
}
out = append(out, spec)
}
return out
}
func appActionSpecsFromJSON(manifestJSON string) ([]store.AppActionSpec, error) {
var m appmanifest.Manifest
if err := json.Unmarshal([]byte(manifestJSON), &m); err != nil {
return nil, err
}
return appActionSpecs(&m), nil
}
// EnsureAppItemActions backfills the action rows of installs provisioned
// before U11 (the EnsureAppWebhooks pattern). Idempotent; a failure is
// logged per install and never stops the start.
func (s *Server) EnsureAppItemActions(ctx context.Context) {
list, err := s.store.ListInstallsWithoutActions()
if err != nil {
slog.Error("apps: item action backfill: list", "error", err)
return
}
for _, in := range list {
if ctx.Err() != nil {
return
}
if err := s.store.EnsureAppItemActions(in.WorkspaceID, in.InstallID, appActionSpecsFromJSON); err != nil {
slog.Error("apps: item action backfill", "install_id", in.InstallID, "error", err)
}
}
}
// contextViewerVisible is the viewer re-admission for mint and redeem: the
// human item read's own visibility rule (checkItemVisibleQ) with the
// viewer's CURRENT role, memberships and grants (lead ruling, day 88). The
// role is crossWorkspaceRole's for a bearer caller (store.ViewerRoleQ), so a
// platform admin who is not a member does not pass on the admin cookie
// bypass: an app is about to receive the item.
func (s *Server) contextViewerVisible() store.ContextVisibleFunc {
return func(q store.Queryer, item *models.Item, viewerID string) (bool, error) {
// Every read on q, the mint or redeem's own transaction: a pool read
// here would wait for a second connection while holding the first,
// and under load every connection can be held that way (codex r1 on
// U11).
viewer, err := s.store.GetUserQ(q, viewerID)
if err != nil {
return false, err
}
if viewer == nil || viewer.IsDisabled() || viewer.Kind == models.UserKindApp {
return false, nil
}
role, err := s.store.ViewerRoleQ(q, item.WorkspaceID, viewer)
if err != nil {
return false, err
}
if role == "" {
return false, nil
}
return s.checkItemVisibleQ(q, item.WorkspaceID, item, viewer, role, true)
}
}
// handleListItemAppActions: GET …/items/{ref}/app-actions.
func (s *Server) handleListItemAppActions(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
item, err := s.store.ResolveItem(workspaceID, chi.URLParam(r, "itemSlug"))
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
actions, err := s.store.ListItemAppActions(workspaceID, item.CollectionID)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, actions)
}
// handleMintItemAppAction: POST …/items/{ref}/app-actions/{installID}/{actionKey}.
// The caller is the viewer. The mint re-checks, under the redeem's locks,
// everything a redeem will (lead ruling, day 88).
func (s *Server) handleMintItemAppAction(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
viewer := currentUser(r)
if viewer == nil {
writeContextRefused(w)
return
}
item, err := s.store.ResolveItem(workspaceID, chi.URLParam(r, "itemSlug"))
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeContextRefused(w)
return
}
minted, err := s.store.MintContextCode(workspaceID, chi.URLParam(r, "installID"), chi.URLParam(r, "actionKey"),
item.ID, viewer.ID, s.contextViewerVisible())
if errors.Is(err, store.ErrContextCodeRefused) {
writeContextRefused(w)
return
}
if err != nil {
writeInternalError(w, err)
return
}
target, err := contextURL(minted.BaseURL, minted.Path, minted.Code)
if err != nil {
writeContextRefused(w)
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]string{"url": target})
}
// contextURL is {base_url}{path} with pad_context added to the path's own
// query, if it has one.
func contextURL(base, path, code string) (string, error) {
u, err := url.Parse(base + path)
if err != nil {
return "", err
}
q := u.Query()
q.Set("pad_context", code)
u.RawQuery = q.Encode()
return u.String(), nil
}
// appContextRedeemMaxBody bounds the redeem body: one short code.
const appContextRedeemMaxBody = 4 << 10
// AppContextRedeemed is the redeem answer: the action and the item, as the
// app API's item DTO. Never the viewer (§6: the code names an item).
type AppContextRedeemed struct {
ActionKey string `json:"action_key"`
Item AppItem `json:"item"`
}
// appRedeemContext: POST /api/app/v1/workspaces/{ws}/context/redeem.
func (s *Server) appRedeemContext(w http.ResponseWriter, r *http.Request) {
ac := appContextFrom(r)
var in struct {
Code string `json:"code"`
}
if err := decodeJSONWithLimit(r, &in, appContextRedeemMaxBody); err != nil || in.Code == "" {
writeContextRefused(w)
return
}
// §6: a service token only (the route table's Auth column is
// documentation; this is the check). A refused attempt still consumes
// the code, as every refusal does (codex r2 on U11).
if ac.AuthKind != "service" {
if err := s.store.BurnContextCode(ac.InstallID, in.Code); err != nil {
writeInternalError(w, err)
return
}
writeContextRefused(w)
return
}
red, err := s.store.RedeemContextCode(ac.InstallID, ac.Grant.AuthEpoch, in.Code, s.contextViewerVisible())
if errors.Is(err, store.ErrContextCodeRefused) {
writeContextRefused(w)
return
}
if err != nil {
writeInternalError(w, err)
return
}
// The ceiling, as on every app read: the redeem already required a
// current companion, so this refuses only if the token's ceiling and the
// install's companions disagree.
if red.Item.WorkspaceID != ac.WorkspaceID || !appCeilingAllows(r, red.Item.CollectionID) {
writeContextRefused(w)
return
}
coll, err := s.store.GetCollection(red.Item.CollectionID)
if err != nil {
writeInternalError(w, err)
return
}
if coll == nil {
writeContextRefused(w)
return
}
dtos, err := s.appItemDTOs(r, []models.Item{*red.Item}, coll)
if err != nil {
writeInternalError(w, err)
return
}
writeAppJSON(w, http.StatusOK, AppContextRedeemed{ActionKey: red.ActionKey, Item: dtos[0]})
}
+5
View File
@@ -75,4 +75,9 @@ func (s *Server) runAppPendingSweep() {
if n > 0 {
slog.Info("apps: swept expired pending installs", "count", n)
}
// Item-action context codes: single-use, two minutes; nothing reads one
// that is consumed or expired (TASK-3414 U11).
if _, err := s.store.PruneContextCodes(); err != nil {
slog.Warn("apps: context code prune failed", "error", err)
}
}
+1 -1
View File
@@ -204,7 +204,7 @@ func (s *Server) buildProvisionRequest(r *http.Request, workspaceID, ownerID str
ManifestSHA256: p.ManifestSHA256, ManifestVersion: m.Version, ManifestJSON: string(manifestJSON),
AppTitle: m.Title, ServiceAccess: m.Scopes.Service.Access, DelegatedAccess: m.Scopes.Delegated.Access,
RedirectURIs: m.RedirectURIs, SourcePack: m.Origin + "@" + m.Version, DigestsJSON: string(b),
Webhook: appWebhookSpec(m),
Webhook: appWebhookSpec(m), Actions: appActionSpecs(m),
}
return req, derive, nil
}
+1 -1
View File
@@ -288,7 +288,7 @@ func upgradePlanFrom(old, next *appmanifest.Manifest, d *upgradeDiff, fresh *app
ManifestSHA256: fresh.ManifestSHA256, ManifestVersion: next.Version, ManifestJSON: string(manifestJSON),
DigestsJSON: string(digests), ServiceAccess: next.Scopes.Service.Access, DelegatedAccess: next.Scopes.Delegated.Access,
SourcePack: next.Origin + "@" + next.Version, Released: d.Released, Renamed: map[string]string{},
Webhook: appWebhookSpec(next),
Webhook: appWebhookSpec(next), Actions: appActionSpecs(next),
Restrictive: len(d.Released) > 0 ||
accessRank(next.Scopes.Service.Access) < accessRank(old.Scopes.Service.Access) ||
accessRank(next.Scopes.Delegated.Access) < accessRank(old.Scopes.Delegated.Access),
+4
View File
@@ -2108,6 +2108,10 @@ func (s *Server) setupRouter() {
r.Delete("/", s.handleDeleteItem)
r.Post("/restore", s.handleRestoreItem)
r.Post("/move", s.handleMoveItem)
// SPEC-6 U11 (TASK-3414): app item actions for the
// item pane, and the link-out's context-code mint.
r.Get("/app-actions", s.handleListItemAppActions)
r.Post("/app-actions/{installID}/{actionKey}", s.handleMintItemAppAction)
// Content-free flush-watermark stamp from a caught-up
// tab (BUG-3124 unit B). Web-client only; no CLI/MCP.
r.Post("/collab-watermark", s.handleStampCollabWatermark)
+5
View File
@@ -334,6 +334,11 @@ func checkAppDTOKeys(t *testing.T, route string, body any) {
}
case "appMe":
assertKeysWithin(t, route, m, "me", appDTOKeys["me"])
case "appRedeemContext":
if strings.Join(appKeysOf(m), ",") != "action_key,item" {
t.Errorf("%s: top-level keys %v", route, appKeysOf(m))
}
checkItem(t, route+".item", m["item"].(map[string]any))
}
}
@@ -0,0 +1,535 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// TASK-3414 (SPEC-6 U11): item actions and context codes, end to end:
// list and mint as the viewer, redeem as the app.
type u11Fix struct {
appAPIFix
viewer *models.User
viewerTok string
actionID string
}
func u11Fixture(t *testing.T) u11Fix {
t.Helper()
return u11Prepare(t, appAPIFixture(t, "read"))
}
// u11Prepare adds to an app API fixture: the install's manifest (title and
// base_url), one item action on the companion collection, and a viewer who
// is a member with full access and a web session.
func u11Prepare(t *testing.T, base appAPIFix) u11Fix {
t.Helper()
f := u11Fix{appAPIFix: base}
db := f.srv.store.DB()
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
exec(`UPDATE app_installs SET manifest = ? WHERE id = ?`, `{"title":"Portal","base_url":"https://portal.example"}`, f.in.id)
f.actionID = "act-open"
exec(`INSERT INTO app_item_actions (id, install_id, action_key, label, path, collection_ids, revision, active, created_at, updated_at)
VALUES (?, ?, 'open', 'Open in Portal', '/t?view=1', ?, 1, 1, ?, ?)`,
f.actionID, f.in.id, `["`+f.companion.ID+`"]`, time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
f.viewer, f.viewerTok = loginTestUserAs(t, f.srv, "viewer-3414@example.com", "Vera Viewer", "pw-3414-viewer")
exec(`INSERT INTO workspace_members (workspace_id, user_id, role, collection_access, created_at) VALUES (?, ?, 'viewer', 'all', ?)`,
f.ws.ID, f.viewer.ID, time.Now().UTC().Format(time.RFC3339))
return f
}
func (f u11Fix) itemPath(itemID, rest string) string {
return "/api/v1/workspaces/" + f.ws.Slug + "/items/" + itemID + rest
}
// mint mints as the viewer; returns the status, the URL and its code.
func (f u11Fix) mint(t *testing.T, itemID, installID, key string) (int, string, string, string) {
t.Helper()
rr := doRequestWithCookie(f.srv, "POST", f.itemPath(itemID, "/app-actions/"+installID+"/"+key), nil, f.viewerTok)
if rr.Code != http.StatusOK {
return rr.Code, "", "", rr.Body.String()
}
var out map[string]string
parseJSON(t, rr, &out)
u, err := url.Parse(out["url"])
if err != nil {
t.Fatal(err)
}
return rr.Code, out["url"], u.Query().Get("pad_context"), rr.Body.String()
}
// redeem is §6's POST /api/app/v1/context/redeem: no workspace in the path,
// the token's own (codex r1 on U11).
func (f u11Fix) redeem(code string) *httptest.ResponseRecorder {
return appDo(f.srv, "POST", appAPIPrefix+"/context/redeem", f.token, map[string]string{"code": code})
}
func TestTask3414_MintRedeemAndReplay(t *testing.T) {
f := u11Fixture(t)
// The list for the item pane.
rr := doRequestWithCookie(f.srv, "GET", f.itemPath(f.item.ID, "/app-actions"), nil, f.viewerTok)
if rr.Code != http.StatusOK {
t.Fatalf("list: %d %s", rr.Code, rr.Body.String())
}
var actions []store.ItemAppAction
parseJSON(t, rr, &actions)
if len(actions) != 1 || actions[0].InstallID != f.in.id || actions[0].ActionKey != "open" || actions[0].Label != "Open in Portal" || actions[0].AppTitle != "Portal" {
t.Fatalf("list: %+v", actions)
}
// Not offered on a collection the action does not name.
rr = doRequestWithCookie(f.srv, "GET", f.itemPath(f.privateItem.ID, "/app-actions"), nil, f.viewerTok)
if !strings.Contains(rr.Body.String(), "[]") {
t.Fatalf("list on another collection: %d %s", rr.Code, rr.Body.String())
}
code, target, ctx, body := f.mint(t, f.item.ID, f.in.id, "open")
if code != http.StatusOK || !strings.HasPrefix(target, "https://portal.example/t?") || !strings.HasPrefix(ctx, "padctx_") || len(ctx) != len("padctx_")+32 {
t.Fatalf("mint: %d %s %s", code, target, body)
}
if u, _ := url.Parse(target); u.Query().Get("view") != "1" {
t.Fatalf("the action's own query was lost: %s", target)
}
rr = f.redeem(ctx)
if rr.Code != http.StatusOK {
t.Fatalf("redeem: %d %s", rr.Code, rr.Body.String())
}
var red map[string]any
parseJSON(t, rr, &red)
item, _ := red["item"].(map[string]any)
if red["action_key"] != "open" || item["id"] != f.item.ID || item["collection"] != f.companion.Slug {
t.Fatalf("redeem body: %s", rr.Body.String())
}
for _, k := range []string{"viewer", "viewer_user_id", "user_id", "email"} {
if _, ok := red[k]; ok {
t.Errorf("the redeem names the viewer (%q): %s", k, rr.Body.String())
}
}
// Single use: a replay is the same refusal as an unknown code.
replay := f.redeem(ctx)
unknown := f.redeem("padctx_00000000000000000000000000000000")
if replay.Code != http.StatusNotFound || replay.Body.String() != unknown.Body.String() {
t.Fatalf("replay %d %q, unknown %d %q", replay.Code, replay.Body.String(), unknown.Code, unknown.Body.String())
}
}
// Every refusal of a redeem is one body, whatever the reason; and a
// mismatch burns the code (lead ruling, day 88: consume first).
func TestTask3414_RedeemRefusalsAreIndistinguishable(t *testing.T) {
f := u11Fixture(t)
db := f.srv.store.DB()
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
want := f.redeem("padctx_00000000000000000000000000000000")
if want.Code != http.StatusNotFound {
t.Fatalf("unknown code: %d %s", want.Code, want.Body.String())
}
for _, tc := range []struct {
name string
breakIt func(code string)
undo func()
}{
{"expired", func(code string) {
exec(`UPDATE app_context_codes SET expires_at = '2000-01-01T00:00:00.000Z'`)
}, func() {}},
{"viewer removed from the workspace", func(string) {
exec(`DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?`, f.ws.ID, f.viewer.ID)
}, func() {
exec(`INSERT INTO workspace_members (workspace_id, user_id, role, collection_access, created_at) VALUES (?, ?, 'viewer', 'all', ?)`,
f.ws.ID, f.viewer.ID, time.Now().UTC().Format(time.RFC3339))
}},
{"viewer's collection access narrowed", func(string) {
exec(`UPDATE workspace_members SET collection_access = 'specific' WHERE workspace_id = ? AND user_id = ?`, f.ws.ID, f.viewer.ID)
}, func() {
exec(`UPDATE workspace_members SET collection_access = 'all' WHERE workspace_id = ? AND user_id = ?`, f.ws.ID, f.viewer.ID)
}},
{"viewer disabled", func(string) {
exec(`UPDATE users SET disabled_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), f.viewer.ID)
}, func() {
exec(`UPDATE users SET disabled_at = NULL WHERE id = ?`, f.viewer.ID)
}},
{"item moved out of the companions", func(string) {
exec(`UPDATE items SET collection_id = ? WHERE id = ?`, f.private.ID, f.item.ID)
}, func() {
exec(`UPDATE items SET collection_id = ? WHERE id = ?`, f.companion.ID, f.item.ID)
}},
{"item deleted", func(string) {
exec(`UPDATE items SET deleted_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), f.item.ID)
}, func() {
exec(`UPDATE items SET deleted_at = NULL WHERE id = ?`, f.item.ID)
}},
{"action changed by an upgrade", func(string) {
exec(`UPDATE app_item_actions SET revision = revision + 1 WHERE id = ?`, f.actionID)
}, func() {}},
{"action removed by an upgrade", func(string) {
exec(`UPDATE app_item_actions SET active = 0, revision = revision + 1 WHERE id = ?`, f.actionID)
}, func() {
exec(`UPDATE app_item_actions SET active = 1 WHERE id = ?`, f.actionID)
}},
{"action deactivated at the same revision", func(string) {
exec(`UPDATE app_item_actions SET active = 0 WHERE id = ?`, f.actionID)
}, func() {
exec(`UPDATE app_item_actions SET active = 1 WHERE id = ?`, f.actionID)
}},
{"item moved to a companion the action is not offered on", func(string) {
exec(`UPDATE items SET collection_id = ? WHERE id = ?`, f.otherCompanion(t), f.item.ID)
}, func() {
exec(`UPDATE items SET collection_id = ? WHERE id = ?`, f.companion.ID, f.item.ID)
}},
{"companion released", func(string) {
exec(`UPDATE collections SET via_app = NULL WHERE id = ?`, f.companion.ID)
}, func() {
exec(`UPDATE collections SET via_app = ? WHERE id = ?`, f.in.id, f.companion.ID)
}},
} {
t.Run(tc.name, func(t *testing.T) {
status, _, code, body := f.mint(t, f.item.ID, f.in.id, "open")
if status != http.StatusOK {
t.Fatalf("mint: %d %s", status, body)
}
tc.breakIt(code)
rr := f.redeem(code)
tc.undo()
if rr.Code != want.Code || rr.Body.String() != want.Body.String() {
t.Fatalf("refusal %d %q differs from the unknown-code refusal %d %q", rr.Code, rr.Body.String(), want.Code, want.Body.String())
}
// Burned: with everything restored, the same code still fails.
if again := f.redeem(code); again.Code != http.StatusNotFound {
t.Fatalf("a refused code was redeemable afterwards: %d %s", again.Code, again.Body.String())
}
})
}
}
// The mint re-checks what the redeem will (lead ruling, day 88), with one
// refusal body.
func TestTask3414_MintRefusalsAreIndistinguishable(t *testing.T) {
f := u11Fixture(t)
status, _, _, want := f.mint(t, f.item.ID, "no-such-install", "open")
if status != http.StatusNotFound {
t.Fatalf("unknown install: %d %s", status, want)
}
other, err := f.srv.store.CreateItem(f.ws.ID, f.private.ID, models.ItemCreate{Title: "Elsewhere"})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
item, install, key string
prep, undo func()
}{
{"unknown action", f.item.ID, f.in.id, "nope", func() {}, func() {}},
{"item outside the action's collections", other.ID, f.in.id, "open", func() {}, func() {}},
{"unknown item", "no-such-item", f.in.id, "open", func() {}, func() {}},
{"viewer cannot see the item", f.item.ID, f.in.id, "open", func() {
_, _ = f.srv.store.DB().Exec(`UPDATE workspace_members SET collection_access = 'specific' WHERE workspace_id = ? AND user_id = ?`, f.ws.ID, f.viewer.ID)
}, func() {
_, _ = f.srv.store.DB().Exec(`UPDATE workspace_members SET collection_access = 'all' WHERE workspace_id = ? AND user_id = ?`, f.ws.ID, f.viewer.ID)
}},
{"companion released", f.item.ID, f.in.id, "open", func() {
_, _ = f.srv.store.DB().Exec(`UPDATE collections SET via_app = NULL WHERE id = ?`, f.companion.ID)
}, func() {
_, _ = f.srv.store.DB().Exec(`UPDATE collections SET via_app = ? WHERE id = ?`, f.in.id, f.companion.ID)
}},
{"install disabled", f.item.ID, f.in.id, "open", func() {
_, _ = f.srv.store.DB().Exec(`UPDATE app_installs SET state = 'inactive' WHERE id = ?`, f.in.id)
}, func() {
_, _ = f.srv.store.DB().Exec(`UPDATE app_installs SET state = 'active' WHERE id = ?`, f.in.id)
}},
} {
t.Run(tc.name, func(t *testing.T) {
tc.prep()
status, _, _, body := f.mint(t, tc.item, tc.install, tc.key)
tc.undo()
if status != http.StatusNotFound || body != want {
t.Fatalf("refusal %d %q differs from %q", status, body, want)
}
})
}
if n := countRows(t, f.srv, `SELECT COUNT(*) FROM app_context_codes`); n != 0 {
t.Fatalf("%d codes minted by refused mints", n)
}
}
// The lead's pin (day 88): access revoked between mint and redeem refuses
// the redeem, read from the viewer's CURRENT memberships, not the mint's.
func TestTask3414_RevokedBetweenMintAndRedeem(t *testing.T) {
f := u11Fixture(t)
status, _, code, body := f.mint(t, f.item.ID, f.in.id, "open")
if status != http.StatusOK {
t.Fatalf("mint: %d %s", status, body)
}
// The viewer is removed from the workspace after the click.
if _, err := f.srv.store.DB().Exec(`DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?`, f.ws.ID, f.viewer.ID); err != nil {
t.Fatal(err)
}
if rr := f.redeem(code); rr.Code != http.StatusNotFound {
t.Fatalf("redeem after the viewer lost access: %d %s", rr.Code, rr.Body.String())
}
}
// Provisioning writes the manifest's actions; a reviewed upgrade that
// changes one gives it a new revision, one that removes it retires it;
// uninstall deletes them; installs from before U11 are backfilled.
func TestTask3414_ActionRowsFollowTheManifest(t *testing.T) {
u := newUpgradeEnv(t)
db := u.srv.store.DB()
row := func() (label, path, colls string, rev int, active bool) {
t.Helper()
if err := db.QueryRow(`SELECT label, path, collection_ids, revision, active FROM app_item_actions WHERE install_id = ? AND action_key = 'open'`, u.installID).
Scan(&label, &path, &colls, &rev, &active); err != nil {
t.Fatal(err)
}
return
}
companion, err := u.srv.store.GetCollectionBySlug(u.wsID, "portal-tickets")
if err != nil || companion == nil {
t.Fatal(err)
}
if label, path, colls, rev, active := row(); label != "Open" || path != "/t" || colls != `["`+companion.ID+`"]` || rev != 1 || !active {
t.Fatalf("provisioned action: %s %s %s %d %v", label, path, colls, rev, active)
}
upgrade := func(version string, actions []any) {
t.Helper()
u.m["version"] = version
u.m["item_actions"] = actions
u.publish(t, u.m)
_, p, _ := u.previewUpgrade(t)
if code, body := u.confirmUpgrade(t, p); code != http.StatusOK {
t.Fatalf("upgrade %s: %d %s", version, code, body)
}
}
upgrade("1.0.1", []any{map[string]any{"key": "open", "label": "Open ticket", "collections": []any{"tickets"}, "path": "/t"}})
if label, _, _, rev, active := row(); label != "Open ticket" || rev != 2 || !active {
t.Fatalf("changed action: %s rev %d active %v", label, rev, active)
}
upgrade("1.0.2", []any{map[string]any{"key": "open", "label": "Open ticket", "collections": []any{"tickets"}, "path": "/t"}})
if _, _, _, rev, _ := row(); rev != 2 {
t.Fatalf("an unchanged action got revision %d", rev)
}
upgrade("1.0.3", []any{})
if _, _, _, rev, active := row(); active || rev != 3 {
t.Fatalf("removed action: rev %d active %v, want retired", rev, active)
}
// Backfill: an install from before U11 gets its rows at start.
if _, err := db.Exec(`DELETE FROM app_item_actions WHERE install_id = ?`, u.installID); err != nil {
t.Fatal(err)
}
upgrade("1.0.4", []any{map[string]any{"key": "open", "label": "Open", "collections": []any{"tickets"}, "path": "/t"}})
if _, err := db.Exec(`DELETE FROM app_item_actions WHERE install_id = ?`, u.installID); err != nil {
t.Fatal(err)
}
u.srv.EnsureAppItemActions(context.Background())
if label, _, _, rev, active := row(); label != "Open" || rev != 1 || !active {
t.Fatalf("backfilled action: %s rev %d active %v", label, rev, active)
}
// Uninstall deletes them.
rr := doRequestWithCookie(u.srv, "POST", "/api/v1/workspaces/"+u.ws+"/apps/"+u.installID+"/uninstall", nil, u.token)
if rr.Code != http.StatusOK {
t.Fatalf("uninstall: %d %s", rr.Code, rr.Body.String())
}
if n := countRows(t, u.srv, `SELECT COUNT(*) FROM app_item_actions WHERE install_id = ?`, u.installID); n != 0 {
t.Fatalf("%d action rows after uninstall", n)
}
}
// otherCompanion is a second companion collection of the install, which the
// action is not offered on (created once).
func (f u11Fix) otherCompanion(t *testing.T) string {
t.Helper()
if c, _ := f.srv.store.GetCollectionBySlug(f.ws.ID, "requests-two"); c != nil {
return c.ID
}
c, err := f.srv.store.CreateCollection(f.ws.ID, models.CollectionCreate{Name: "Requests two", Slug: "requests-two", Schema: `{"fields":[]}`})
if err != nil {
t.Fatal(err)
}
if _, err := f.srv.store.DB().Exec(`UPDATE collections SET via_app = ? WHERE id = ?`, f.in.id, c.ID); err != nil {
t.Fatal(err)
}
return c.ID
}
// A code minted before the install's epoch moved (a rotate) is refused even
// with a token from the new epoch: the code is bound to the epoch it was
// minted under (§6).
func TestTask3414_CodeFromAnEarlierEpochIsRefused(t *testing.T) {
f := u11Fixture(t)
status, _, code, body := f.mint(t, f.item.ID, f.in.id, "open")
if status != http.StatusOK {
t.Fatalf("mint: %d %s", status, body)
}
if _, err := f.srv.store.DB().Exec(`UPDATE app_installs SET auth_epoch = auth_epoch + 1 WHERE id = ?`, f.in.id); err != nil {
t.Fatal(err)
}
f.token = mintServiceToken(t, f.srv, f.in)
if rr := f.redeem(code); rr.Code != http.StatusNotFound {
t.Fatalf("redeem of a code from an earlier epoch: %d %s", rr.Code, rr.Body.String())
}
// A code minted now, at the new epoch, redeems.
status, _, code, body = f.mint(t, f.item.ID, f.in.id, "open")
if status != http.StatusOK {
t.Fatalf("mint at the new epoch: %d %s", status, body)
}
if rr := f.redeem(code); rr.Code != http.StatusOK {
t.Fatalf("redeem at the new epoch: %d %s", rr.Code, rr.Body.String())
}
}
// A platform admin who is not a member of the workspace is not a viewer an
// app may be handed an item for: the membership-only stance, not the admin
// cookie bypass.
func TestTask3414_NonMemberAdminCannotLaunch(t *testing.T) {
f := u11Fixture(t)
admin, tok := loginTestUserAs(t, f.srv, "admin-3414@example.com", "Ada Admin", "pw-3414-admin")
if _, err := f.srv.store.DB().Exec(`UPDATE users SET role = 'admin' WHERE id = ?`, admin.ID); err != nil {
t.Fatal(err)
}
rr := doRequestWithCookie(f.srv, "POST", f.itemPath(f.item.ID, "/app-actions/"+f.in.id+"/open"), nil, tok)
if rr.Code != http.StatusNotFound {
t.Fatalf("a non-member admin minted a code: %d %s", rr.Code, rr.Body.String())
}
}
// The unscoped route census: redeem's answer is {action_key, item} with the
// app item DTO's keys, and it is the one root-mounted route.
func TestTask3414_RedeemRouteCensus(t *testing.T) {
if len(appUnscopedRoutes) != 1 || appUnscopedRoutes[0].Name != "appRedeemContext" {
t.Fatalf("unscoped routes: %+v", appUnscopedRoutes)
}
f := u11Fixture(t)
_, _, code, body := f.mint(t, f.item.ID, f.in.id, "open")
if code == "" {
t.Fatalf("mint: %s", body)
}
rr := f.redeem(code)
if rr.Code != http.StatusOK || !strings.HasPrefix(rr.Header().Get("Content-Type"), "application/json") {
t.Fatalf("redeem: %d %s", rr.Code, rr.Body.String())
}
var m map[string]any
parseJSON(t, rr, &m)
if strings.Join(appKeysOf(m), ",") != "action_key,item" {
t.Fatalf("top-level keys %v", appKeysOf(m))
}
checkItem(t, "appRedeemContext.item", m["item"].(map[string]any))
// The workspace route of the same name does not exist: nothing can be
// refused at a workspace check before the code is consumed.
if rr := appDo(f.srv, "POST", f.path("/context/redeem"), f.token, map[string]string{"code": "x"}); rr.Code != http.StatusNotFound && rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("a workspace-scoped redeem path answered %d", rr.Code)
}
}
// §6: a service token only; a person's delegated token is refused with the
// same body.
func TestTask3414_DelegatedTokenCannotRedeem(t *testing.T) {
d := delegatedAPIFixture(t, "read", "read", "editor")
f := u11Prepare(t, d.appAPIFix)
_, _, code, body := f.mint(t, f.item.ID, f.in.id, "open")
if code == "" {
t.Fatalf("mint: %s", body)
}
want := f.redeem("padctx_00000000000000000000000000000000")
got := f.redeem(code)
if got.Code != http.StatusNotFound || got.Body.String() != want.Body.String() {
t.Fatalf("a delegated redeem: %d %s", got.Code, got.Body.String())
}
// The refused attempt consumed the code: the app's service token
// cannot redeem it afterwards (codex r2 on U11).
f.token = mintServiceToken(t, f.srv, f.in)
if rr := f.redeem(code); rr.Code != http.StatusNotFound {
t.Fatalf("a service redeem after a delegated attempt: %d %s", rr.Code, rr.Body.String())
}
}
// codex r1 on U11: the URL is built from the validated origin, not the raw
// stored base_url.
func TestTask3414_URLUsesTheNormalizedOrigin(t *testing.T) {
f := u11Fixture(t)
if _, err := f.srv.store.DB().Exec(`UPDATE app_installs SET manifest = ? WHERE id = ?`, `{"title":"Portal","base_url":" https://Portal.Example:443 "}`, f.in.id); err != nil {
t.Fatal(err)
}
status, target, _, body := f.mint(t, f.item.ID, f.in.id, "open")
if status != http.StatusOK || !strings.HasPrefix(target, "https://portal.example/t?") {
t.Fatalf("mint: %d %s %s", status, target, body)
}
}
// codex r1 on U11: the mint and redeem transactions read nothing through the
// pool. With ONE connection, a pool read inside them deadlocks
// deterministically. Every viewer branch is armed: a full member, a
// restricted member (collection access list), a non-member guest (an item
// grant).
func TestTask3414_NoPoolReadsInsideTheTransactions(t *testing.T) {
f := u11Fixture(t)
db := f.srv.store.DB()
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
ts := time.Now().UTC().Format(time.RFC3339)
restricted, restrictedTok := loginTestUserAs(t, f.srv, "restricted-3414@example.com", "Rae", "pw-3414-r")
exec(`INSERT INTO workspace_members (workspace_id, user_id, role, collection_access, created_at) VALUES (?, ?, 'viewer', 'specific', ?)`, f.ws.ID, restricted.ID, ts)
exec(`INSERT INTO member_collection_access (workspace_id, user_id, collection_id, created_at) VALUES (?, ?, ?, ?)`, f.ws.ID, restricted.ID, f.companion.ID, ts)
guest, guestTok := loginTestUserAs(t, f.srv, "guest-3414@example.com", "Gus", "pw-3414-g")
if _, err := f.srv.store.CreateItemGrant(f.ws.ID, f.item.ID, guest.ID, "view", f.viewer.ID); err != nil {
t.Fatal(err)
}
was := db.Stats().MaxOpenConnections
db.SetMaxOpenConns(1)
t.Cleanup(func() { db.SetMaxOpenConns(was) })
for _, v := range []struct {
name, tok string
}{{"full member", f.viewerTok}, {"restricted member", restrictedTok}, {"guest", guestTok}} {
t.Run(v.name, func(t *testing.T) {
done := make(chan string, 1)
go func() {
rr := doRequestWithCookie(f.srv, "POST", f.itemPath(f.item.ID, "/app-actions/"+f.in.id+"/open"), nil, v.tok)
if rr.Code != http.StatusOK {
done <- "mint " + rr.Body.String()
return
}
var out map[string]string
_ = json.Unmarshal(rr.Body.Bytes(), &out)
u, _ := url.Parse(out["url"])
red := f.redeem(u.Query().Get("pad_context"))
if red.Code != http.StatusOK {
done <- "redeem " + red.Body.String()
return
}
done <- ""
}()
select {
case msg := <-done:
if msg != "" {
t.Fatal(msg)
}
case <-time.After(20 * time.Second):
t.Fatal("mint or redeem hung on a one-connection pool: a pool read inside the transaction")
}
})
}
}
+580
View File
@@ -0,0 +1,580 @@
package store
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"github.com/PerpetualSoftware/pad/internal/appmanifest"
"github.com/PerpetualSoftware/pad/internal/models"
)
// App item actions and context codes (SPEC-6 U11, DOC-3371 §6; TASK-3414).
//
// An install's manifest declares item actions: a labelled link-out offered
// on items of some of its companion collections. A viewer's click MINTS a
// single-use context code bound to (install, auth_epoch, item, action,
// action revision, viewer) and is sent to {base_url}{path}?pad_context=code;
// the app REDEEMS the code with its service token and receives the item.
//
// LOCKS (lead ruling 3, round-4 R4-3). Mint and redeem take, in this order,
// the install row FOR SHARE, the item row FOR SHARE and the action row FOR
// SHARE (Postgres; SQLite's BEGIN IMMEDIATE serializes). Every mutation that
// can invalidate a code takes those rows exclusively, in the same order:
// - disable, rotate and uninstall hold the install row FOR UPDATE (U8c);
// - an upgrade holds the install row FOR UPDATE, then UPDATEs the action
// rows (syncAppItemActionsTx);
// - item move and delete UPDATE the item row, which takes its row lock (FOR
// NO KEY UPDATE, conflicting with FOR SHARE). The human paths hold no
// install row, so they cannot form a cycle with install -> item; the app
// path takes the install row first (FencedTx), the same order.
//
// EVERY refusal of a mint or a redeem is ErrContextCodeRefused, which the
// HTTP layer answers with one 404 body: unknown, expired, consumed, another
// install's, moved, deleted, upgraded, disabled, no longer visible. A redeem
// CONSUMES the code before it checks anything, so a mismatch burns it.
// ErrContextCodeRefused is every refusal of a mint or a redeem.
var ErrContextCodeRefused = errors.New("context code refused")
// contextCodeTTLSeconds is a code's lifetime: long enough for the browser
// round trip to the app and the app's redeem call, short enough that a code
// in a log or a history entry is useless by the time anyone reads it (§6).
const contextCodeTTLSeconds = 120
// AppActionSpec is one manifest item action, by companion collection SLUG.
type AppActionSpec struct {
Key string
Label string
Path string
CollectionSlugs []string
}
type appActionRow struct {
id, key, label, path string
collections []string
revision int
active bool
}
func (s *Store) loadAppActionsTx(tx *sql.Tx, installID string) (map[string]appActionRow, error) {
rows, err := tx.Query(s.q(`SELECT id, action_key, label, path, collection_ids, revision, active FROM app_item_actions WHERE install_id = ?`), installID)
if err != nil {
return nil, fmt.Errorf("app actions: read: %w", err)
}
defer rows.Close()
out := map[string]appActionRow{}
for rows.Next() {
var r appActionRow
var colls string
if err := rows.Scan(&r.id, &r.key, &r.label, &r.path, &colls, &r.revision, &r.active); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(colls), &r.collections); err != nil {
return nil, fmt.Errorf("app actions: collections of %s: %w", r.key, err)
}
out[r.key] = r
}
return out, rows.Err()
}
// syncAppItemActionsTx makes the install's action rows match specs, on the
// caller's transaction (provisioning, upgrade, the backfill), which holds the
// install row. A changed action (label, path or collections) gets a new
// revision; a removed one is kept inactive, so a code minted for it before is
// refused; a re-added one is reactivated with a new revision.
func (s *Store) syncAppItemActionsTx(tx *sql.Tx, workspaceID, installID string, specs []AppActionSpec) error {
existing, err := s.loadAppActionsTx(tx, installID)
if err != nil {
return err
}
ts := now()
seen := map[string]bool{}
for _, spec := range specs {
seen[spec.Key] = true
ids := make([]string, 0, len(spec.CollectionSlugs))
for _, slug := range spec.CollectionSlugs {
var id string
err := tx.QueryRow(s.q(`SELECT id FROM collections WHERE workspace_id = ? AND slug = ? AND via_app = ? AND deleted_at IS NULL`),
workspaceID, slug, installID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return &ProvisionConflictError{Collection: slug, Field: "item_actions",
Detail: fmt.Sprintf("the item action %q names a companion collection this install does not hold", spec.Key)}
}
if err != nil {
return fmt.Errorf("app actions: resolve %q: %w", slug, err)
}
ids = append(ids, id)
}
colls, _ := json.Marshal(ids)
old, ok := existing[spec.Key]
if !ok {
if _, err := tx.Exec(s.q(`INSERT INTO app_item_actions (id, install_id, action_key, label, path, collection_ids, revision, active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`),
newID(), installID, spec.Key, spec.Label, spec.Path, string(colls), s.dialect.BoolToInt(true), ts, ts); err != nil {
return fmt.Errorf("app actions: insert %q: %w", spec.Key, err)
}
continue
}
if old.active && old.label == spec.Label && old.path == spec.Path && slices.Equal(old.collections, ids) {
continue
}
if _, err := tx.Exec(s.q(`UPDATE app_item_actions SET label = ?, path = ?, collection_ids = ?, revision = revision + 1, active = ?, updated_at = ? WHERE id = ?`),
spec.Label, spec.Path, string(colls), s.dialect.BoolToInt(true), ts, old.id); err != nil {
return fmt.Errorf("app actions: update %q: %w", spec.Key, err)
}
}
for key, old := range existing {
if seen[key] || !old.active {
continue
}
if _, err := tx.Exec(s.q(`UPDATE app_item_actions SET active = ?, revision = revision + 1, updated_at = ? WHERE id = ?`),
s.dialect.BoolToInt(false), ts, old.id); err != nil {
return fmt.Errorf("app actions: retire %q: %w", key, err)
}
}
return nil
}
func (s *Store) deleteAppItemActionsTx(tx *sql.Tx, installID string) error {
if _, err := tx.Exec(s.q(`DELETE FROM app_item_actions WHERE install_id = ?`), installID); err != nil {
return fmt.Errorf("app actions: delete: %w", err)
}
if _, err := tx.Exec(s.q(`DELETE FROM app_context_codes WHERE install_id = ?`), installID); err != nil {
return fmt.Errorf("app actions: delete codes: %w", err)
}
return nil
}
// ItemAppAction is one action offered on an item, for the item pane.
type ItemAppAction struct {
InstallID string `json:"install_id"`
AppTitle string `json:"app_title"`
ActionKey string `json:"action_key"`
Label string `json:"label"`
}
// ListItemAppActions returns the active actions of the workspace's active
// installs that are offered on collectionID. The caller has already decided
// the viewer can see the item; a click is re-checked at mint.
func (s *Store) ListItemAppActions(workspaceID, collectionID string) ([]ItemAppAction, error) {
rows, err := s.db.Query(s.q(`SELECT a.install_id, a.action_key, a.label, a.collection_ids, i.manifest
FROM app_item_actions a JOIN app_installs i ON i.id = a.install_id
WHERE i.workspace_id = ? AND i.state = 'active' AND a.active = ?
ORDER BY i.created_at, a.action_key`), workspaceID, s.dialect.BoolToInt(true))
if err != nil {
return nil, fmt.Errorf("list item app actions: %w", err)
}
defer rows.Close()
out := []ItemAppAction{}
for rows.Next() {
var a ItemAppAction
var colls string
var manifest sql.NullString
if err := rows.Scan(&a.InstallID, &a.ActionKey, &a.Label, &colls, &manifest); err != nil {
return nil, err
}
var ids []string
if err := json.Unmarshal([]byte(colls), &ids); err != nil || !slices.Contains(ids, collectionID) {
continue
}
a.AppTitle = manifestField(manifest.String, "title")
out = append(out, a)
}
return out, rows.Err()
}
// manifestField reads one top-level string from a stored manifest.
func manifestField(manifestJSON, key string) string {
var m map[string]any
if json.Unmarshal([]byte(manifestJSON), &m) != nil {
return ""
}
v, _ := m[key].(string)
return v
}
// ContextVisibleFunc decides, on q, whether viewerID can see item: the human
// item read's own rule, with the viewer's current memberships and grants
// (lead ruling, day 88). Supplied by the server.
type ContextVisibleFunc func(q Queryer, item *models.Item, viewerID string) (bool, error)
// contextLocksQ takes the §6 locks in order on q (install, item, action) and
// returns what the checks need. Every miss is ErrContextCodeRefused.
type contextLocked struct {
state, workspaceID, manifest string
epoch int64
item *models.Item
action appActionRow
}
func (s *Store) contextLocksTx(tx *sql.Tx, installID, itemID, actionKey string) (*contextLocked, error) {
share := ""
if s.dialect.Driver() == DriverPostgres {
share = " FOR SHARE"
}
var c contextLocked
var manifest sql.NullString
err := tx.QueryRow(s.q(`SELECT state, workspace_id, auth_epoch, manifest FROM app_installs WHERE id = ?`+share), installID).
Scan(&c.state, &c.workspaceID, &c.epoch, &manifest)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrContextCodeRefused
}
if err != nil {
return nil, fmt.Errorf("context code: install: %w", err)
}
c.manifest = manifest.String
var id string
err = tx.QueryRow(s.q(`SELECT id FROM items WHERE id = ? AND deleted_at IS NULL`+share), itemID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrContextCodeRefused
}
if err != nil {
return nil, fmt.Errorf("context code: item: %w", err)
}
if c.item, err = s.getItemTx(tx, itemID); err != nil || c.item == nil {
if err != nil {
return nil, err
}
return nil, ErrContextCodeRefused
}
var colls string
err = tx.QueryRow(s.q(`SELECT id, action_key, label, path, collection_ids, revision, active FROM app_item_actions WHERE install_id = ? AND action_key = ?`+share),
installID, actionKey).Scan(&c.action.id, &c.action.key, &c.action.label, &c.action.path, &colls, &c.action.revision, &c.action.active)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrContextCodeRefused
}
if err != nil {
return nil, fmt.Errorf("context code: action: %w", err)
}
if err := json.Unmarshal([]byte(colls), &c.action.collections); err != nil {
return nil, fmt.Errorf("context code: action collections: %w", err)
}
return &c, nil
}
// contextAdmissibleQ is the shared check, under the locks, for mint and
// redeem: install active in the item's workspace, item in one of the
// install's CURRENT companion collections that the action is offered on,
// action active, viewer can see the item.
func (s *Store) contextAdmissibleQ(q Queryer, c *contextLocked, installID, viewerID string, visible ContextVisibleFunc) error {
if c.state != InstallActive || c.item.WorkspaceID != c.workspaceID {
return ErrContextCodeRefused
}
if !c.action.active || !slices.Contains(c.action.collections, c.item.CollectionID) {
return ErrContextCodeRefused
}
var n int
if err := q.QueryRow(s.q(`SELECT COUNT(*) FROM collections WHERE id = ? AND via_app = ? AND deleted_at IS NULL`),
c.item.CollectionID, installID).Scan(&n); err != nil {
return fmt.Errorf("context code: companion: %w", err)
}
if n == 0 {
return ErrContextCodeRefused
}
var live int
if err := q.QueryRow(s.q(`SELECT COUNT(*) FROM workspaces WHERE id = ? AND deleted_at IS NULL`), c.workspaceID).Scan(&live); err != nil {
return fmt.Errorf("context code: workspace: %w", err)
}
if live == 0 {
return ErrContextCodeRefused
}
ok, err := visible(q, c.item, viewerID)
if err != nil {
return err
}
if !ok {
return ErrContextCodeRefused
}
return nil
}
// MintedContext is a minted code and where to send it.
type MintedContext struct {
Code string
BaseURL string
Path string
}
// MintContextCode mints a code for viewerID's click on actionKey of
// installID for itemID in workspaceID, after the same checks redeem makes,
// under the same locks (lead ruling, day 88).
func (s *Store) MintContextCode(workspaceID, installID, actionKey, itemID, viewerID string, visible ContextVisibleFunc) (*MintedContext, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
c, err := s.contextLocksTx(tx, installID, itemID, actionKey)
if err != nil {
return nil, err
}
if c.workspaceID != workspaceID {
return nil, ErrContextCodeRefused
}
if err := s.contextAdmissibleQ(tx, c, installID, viewerID, visible); err != nil {
return nil, err
}
// The validated origin, not the raw field: validation trims base_url
// while it derives the origin, but the stored manifest keeps the
// original string (codex r1 on U11). Refused before a code is written.
base, err := appmanifest.NormalizeOrigin(manifestField(c.manifest, "base_url"))
if err != nil {
return nil, ErrContextCodeRefused
}
raw := make([]byte, 16) // 128 bits (§6)
if _, err := rand.Read(raw); err != nil {
return nil, fmt.Errorf("context code: %w", err)
}
code := "padctx_" + hex.EncodeToString(raw)
expires := fmt.Sprintf(`strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ', 'now', '+%d seconds')`, contextCodeTTLSeconds)
if s.dialect.Driver() == DriverPostgres {
expires = fmt.Sprintf(`now() + interval '%d seconds'`, contextCodeTTLSeconds)
}
if _, err := tx.Exec(s.q(`INSERT INTO app_context_codes (code_sha256, install_id, auth_epoch, item_id, action_id, action_revision, viewer_user_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, `+expires+`)`),
contextCodeHash(code), installID, c.epoch, itemID, c.action.id, c.action.revision, viewerID); err != nil {
return nil, fmt.Errorf("context code: insert: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &MintedContext{Code: code, BaseURL: base, Path: c.action.path}, nil
}
func contextCodeHash(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
// RedeemedContext is what a redeem hands the app: the action and the item,
// read under the locks. Never the viewer (§6: the code names an item).
type RedeemedContext struct {
ActionKey string
Item *models.Item
}
// RedeemContextCode consumes code for installID, whose token carries epoch,
// and returns the item it names if, under the §6 locks, everything the code
// was bound to still holds. The code is consumed FIRST and stays consumed
// whatever the outcome (a mismatch burns it).
func (s *Store) RedeemContextCode(installID string, epoch int64, code string, visible ContextVisibleFunc) (*RedeemedContext, error) {
hash := contextCodeHash(code)
// What the code names, read first to know which rows to lock. The
// consume below decides whether it is still live.
var codeInstall, itemID, actionID, viewerID string
var codeEpoch int64
var revision int
err := s.db.QueryRow(s.q(`SELECT install_id, auth_epoch, item_id, action_id, action_revision, viewer_user_id FROM app_context_codes WHERE code_sha256 = ?`), hash).
Scan(&codeInstall, &codeEpoch, &itemID, &actionID, &revision, &viewerID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrContextCodeRefused
}
if err != nil {
return nil, fmt.Errorf("redeem context: %w", err)
}
var actionKey string
if err := s.db.QueryRow(s.q(`SELECT action_key FROM app_item_actions WHERE id = ?`), actionID).Scan(&actionKey); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// The action row is gone (uninstall): burn the code and refuse.
_ = s.burnContextCode(hash)
return nil, ErrContextCodeRefused
}
return nil, fmt.Errorf("redeem context: %w", err)
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
c, lockErr := s.contextLocksTx(tx, codeInstall, itemID, actionKey)
if lockErr != nil && !errors.Is(lockErr, ErrContextCodeRefused) {
return nil, lockErr
}
// Consume under the locks, whatever follows. Only an unconsumed,
// unexpired code is consumed here; a second redeem finds nothing to
// consume and is refused.
live := `expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
if s.dialect.Driver() == DriverPostgres {
live = `expires_at > now()`
}
res, err := tx.Exec(s.q(`UPDATE app_context_codes SET consumed_at = ? WHERE code_sha256 = ? AND consumed_at IS NULL AND `+live), now(), hash)
if err != nil {
return nil, fmt.Errorf("redeem context: consume: %w", err)
}
consumed, _ := res.RowsAffected()
refuse := func() (*RedeemedContext, error) {
if err := tx.Commit(); err != nil { // the consume stands
return nil, err
}
return nil, ErrContextCodeRefused
}
if consumed == 0 {
// Already consumed or expired: burn it if it was merely expired.
if _, err := tx.Exec(s.q(`UPDATE app_context_codes SET consumed_at = ? WHERE code_sha256 = ? AND consumed_at IS NULL`), now(), hash); err != nil {
return nil, fmt.Errorf("redeem context: burn: %w", err)
}
return refuse()
}
if lockErr != nil {
return refuse()
}
if redeemContextHook != nil {
redeemContextHook()
}
// Bound to THIS install and epoch; the token's epoch is the install's.
if codeInstall != installID || c.epoch != codeEpoch || epoch != codeEpoch {
return refuse()
}
if c.action.id != actionID || c.action.revision != revision {
return refuse()
}
if err := s.contextAdmissibleQ(tx, c, installID, viewerID, visible); err != nil {
if errors.Is(err, ErrContextCodeRefused) {
return refuse()
}
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &RedeemedContext{ActionKey: actionKey, Item: c.item}, nil
}
// redeemContextHook runs inside RedeemContextCode's transaction, with
// every lock held and the code consumed; tests start a competing mutation
// there. nil in production.
var redeemContextHook func()
// BurnContextCode consumes installID's code without redeeming it (an attempt
// refused before the redeem, a delegated token's). Another install's code is
// left alone.
func (s *Store) BurnContextCode(installID, code string) error {
_, err := s.db.Exec(s.q(`UPDATE app_context_codes SET consumed_at = ? WHERE code_sha256 = ? AND install_id = ? AND consumed_at IS NULL`),
now(), contextCodeHash(code), installID)
if err != nil {
return fmt.Errorf("burn context code: %w", err)
}
return nil
}
// burnContextCode marks a code consumed outside a redeem transaction.
func (s *Store) burnContextCode(hash string) error {
_, err := s.db.Exec(s.q(`UPDATE app_context_codes SET consumed_at = ? WHERE code_sha256 = ? AND consumed_at IS NULL`), now(), hash)
return err
}
// PruneContextCodes deletes codes that are consumed or expired, in database
// time. A code is single-use and lives two minutes; nothing reads one after.
func (s *Store) PruneContextCodes() (int64, error) {
q := `DELETE FROM app_context_codes WHERE consumed_at IS NOT NULL OR expires_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
if s.dialect.Driver() == DriverPostgres {
q = `DELETE FROM app_context_codes WHERE consumed_at IS NOT NULL OR expires_at <= now()`
}
res, err := s.db.Exec(q)
if err != nil {
return 0, fmt.Errorf("prune context codes: %w", err)
}
return res.RowsAffected()
}
// AppActionSpecFunc derives the action specs from a stored manifest (the
// server's appmanifest translation).
type AppActionSpecFunc func(manifestJSON string) ([]AppActionSpec, error)
// ListInstallsWithoutActions returns active and inactive installs with a
// stored manifest and no action rows: those provisioned before U11. A work
// list only; EnsureAppItemActions re-decides under the install lock.
func (s *Store) ListInstallsWithoutActions() ([]InstallNeedingWebhook, error) {
rows, err := s.db.Query(s.q(`SELECT i.id, i.workspace_id FROM app_installs i
WHERE i.state IN ('active', 'inactive') AND i.manifest IS NOT NULL AND i.manifest != ''
AND NOT EXISTS (SELECT 1 FROM app_item_actions a WHERE a.install_id = i.id)
ORDER BY i.id`))
if err != nil {
return nil, fmt.Errorf("list installs without actions: %w", err)
}
defer rows.Close()
var out []InstallNeedingWebhook
for rows.Next() {
var r InstallNeedingWebhook
if err := rows.Scan(&r.InstallID, &r.WorkspaceID); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// EnsureAppItemActions writes the action rows an already-installed manifest
// declares (the backfill). Under the install row lock it re-checks the
// state, that there are still no rows, and re-reads the manifest, so a list
// that went stale (an upgrade, another instance's backfill) changes nothing.
func (s *Store) EnsureAppItemActions(workspaceID, installID string, specOf AppActionSpecFunc) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
state, err := s.lockInstallTx(tx, workspaceID, installID)
if err != nil {
return err
}
if state != InstallActive && state != InstallInactive {
return nil
}
var n int
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM app_item_actions WHERE install_id = ?`), installID).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil
}
var manifest sql.NullString
if err := tx.QueryRow(s.q(`SELECT manifest FROM app_installs WHERE id = ?`), installID).Scan(&manifest); err != nil {
return err
}
specs, err := specOf(manifest.String)
if err != nil {
return err
}
if len(specs) == 0 {
return nil
}
if err := s.syncAppItemActionsTx(tx, workspaceID, installID, specs); err != nil {
return err
}
return tx.Commit()
}
// ViewerRoleQ is the role a bearer caller would be admitted with to
// workspaceID (the server's crossWorkspaceRole with isBearer set), read on q
// so the context-code transactions do not take a second connection (codex r1
// on U11): the member's role; nothing for a platform admin who is not a
// member (no admin bypass for an app's viewer); "guest" for a non-member
// holding a grant; otherwise "".
func (s *Store) ViewerRoleQ(q Queryer, workspaceID string, user *models.User) (string, error) {
member, err := s.GetWorkspaceMemberQ(q, workspaceID, user.ID)
if err != nil {
return "", err
}
if member != nil {
return member.Role, nil
}
if user.Role == "admin" {
return "", nil
}
has, err := s.UserHasGrantsInWorkspaceQ(q, workspaceID, user.ID)
if err != nil {
return "", err
}
if has {
return "guest", nil
}
return "", nil
}
+12 -3
View File
@@ -177,7 +177,8 @@ var ErrDrainTimeout = errors.New("app deliveries still in flight")
//
// The webhook needs nothing here: admission refuses every attempt while the
// install is not active, and the drain between the phases waited out every
// attempt admitted before phase 1 (TASK-3408 U10b). TODO(U11): item actions.
// attempt admitted before phase 1 (TASK-3408 U10b). Item actions need nothing
// either: mint and redeem refuse an install that is not active (U11).
func (s *Store) FinishDisable(workspaceID, installID string) error {
tx, err := s.db.Begin()
if err != nil {
@@ -286,8 +287,9 @@ var uninstallHookAfterStep func(step string) error
// DeleteInstallClientTx);
// 2. the app webhook is deleted (U10a); no delivery is in flight, because
// admission refuses an uninstalling install and the drain ran first
// (U10b). Item actions: TODO(U11). App attachments are all bound to
// items and stay (the U7 item-scoped ruling);
// (U10b). Item actions and their context codes are deleted (U11). App
// attachments are all bound to items and stay (the U7 item-scoped
// ruling);
// 3. the bot: its membership (member_collection_access cascades), its
// sessions, API tokens and OAuth credentials, and the bot is DISABLED;
// 4. the install becomes the tombstone, state 'uninstalled'.
@@ -361,6 +363,13 @@ func (s *Store) UninstallAppTx(workspaceID, installID string) error {
if err := step("webhook"); err != nil {
return err
}
// Item actions and their unredeemed context codes (U11).
if err := s.deleteAppItemActionsTx(tx, installID); err != nil {
return fmt.Errorf("uninstall: %w", err)
}
if err := step("actions"); err != nil {
return err
}
var bot sql.NullString
if err := tx.QueryRow(s.q(`SELECT bot_user_id FROM app_installs WHERE id = ?`), installID).Scan(&bot); err != nil {
+7
View File
@@ -115,6 +115,9 @@ type ProvisionRequest struct {
// Webhook is the manifest's hook (events by companion SLUG); nil when it
// declares none. Created HELD: redeem hands the app its secret (U10a).
Webhook *AppWebhookSpec
// Actions are the manifest's item actions (by companion SLUG), written as
// app_item_actions rows (U11, TASK-3414).
Actions []AppActionSpec
}
// ProvisionResult is what a successful provisioning wrote.
@@ -329,6 +332,10 @@ func (s *Store) ProvisionAppInstall(req ProvisionRequest, derive ProvisionDerive
if err := s.upsertAppWebhookTx(tx, req.WorkspaceID, installID, req.Webhook); err != nil {
return nil, err
}
// And its item actions, likewise after the companions (U11).
if err := s.syncAppItemActionsTx(tx, req.WorkspaceID, installID, req.Actions); err != nil {
return nil, err
}
// The install client. Its first secret is discarded unseen: redeem
// rotates and returns the one the app holds (lead ruling, U8b Q2).
+8
View File
@@ -87,6 +87,9 @@ type UpgradePlan struct {
// removes it. An existing hook keeps its secret and delivered state; a
// new one starts HELD until a redeem (U10a).
Webhook *AppWebhookSpec
// Actions are the new manifest's item actions: a changed one gets a new
// revision, a removed one is retired (U11, TASK-3414).
Actions []AppActionSpec
}
// UpgradeRequest identifies the upgrade being confirmed.
@@ -315,6 +318,11 @@ func (s *Store) UpgradeAppInstall(req UpgradeRequest, derive UpgradeDeriveFunc)
if err := s.upsertAppWebhookTx(tx, req.WorkspaceID, req.InstallID, plan.Webhook); err != nil {
return nil, err
}
// The install row is held FOR UPDATE above, so a redeem (install FOR
// SHARE, then the action row) cannot interleave (§6, U11).
if err := s.syncAppItemActionsTx(tx, req.WorkspaceID, req.InstallID, plan.Actions); err != nil {
return nil, err
}
// Changed and added artifacts land as NEW drafts; the installed item is
// never overwritten.
+6 -1
View File
@@ -437,8 +437,13 @@ func (s *Store) ResolveUserPermission(workspaceID, userID, itemID, collectionID
// Grants on soft-deleted items or soft-deleted collections are excluded so
// archived resources don't create phantom guest access.
func (s *Store) UserHasGrantsInWorkspace(workspaceID, userID string) (bool, error) {
return s.UserHasGrantsInWorkspaceQ(s.db, workspaceID, userID)
}
// UserHasGrantsInWorkspaceQ is UserHasGrantsInWorkspace on q.
func (s *Store) UserHasGrantsInWorkspaceQ(q Queryer, workspaceID, userID string) (bool, error) {
var count int
err := s.db.QueryRow(s.q(`
err := q.QueryRow(s.q(`
SELECT COUNT(*) FROM (
SELECT 1 FROM collection_grants cg
JOIN collections c ON c.id = cg.collection_id
@@ -0,0 +1,41 @@
-- Migration 125: app item actions and context codes (SPEC-6 U11, DOC-3371
-- §6; TASK-3414).
--
-- app_item_actions: one row per install and action key, written from the
-- install's manifest at provisioning and by a reviewed upgrade. revision
-- increments whenever an upgrade changes the action (label, path or
-- collections); a removed action is kept with active = 0, so a context code
-- minted before the upgrade names an action that no longer applies and is
-- refused. collection_ids is the JSON array of companion collection ids
-- the action is offered on.
CREATE TABLE IF NOT EXISTS app_item_actions (
id TEXT PRIMARY KEY,
install_id TEXT NOT NULL REFERENCES app_installs(id) ON DELETE CASCADE,
action_key TEXT NOT NULL,
label TEXT NOT NULL,
path TEXT NOT NULL,
collection_ids TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (install_id, action_key)
);
-- app_context_codes: a link-out's single-use code. Only its sha256 is
-- stored. Bound to the install, its auth_epoch, the item, the action and its
-- revision, and the viewer who minted it. Valid two minutes; consumed by the
-- first redeem whatever its outcome. expires_at is database time, written
-- and compared in SQL.
CREATE TABLE IF NOT EXISTS app_context_codes (
code_sha256 TEXT PRIMARY KEY,
install_id TEXT NOT NULL REFERENCES app_installs(id) ON DELETE CASCADE,
auth_epoch INTEGER NOT NULL,
item_id TEXT NOT NULL,
action_id TEXT NOT NULL,
action_revision INTEGER NOT NULL,
viewer_user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
consumed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_app_context_codes_expiry ON app_context_codes (expires_at);
@@ -16,6 +16,13 @@ api_tokens.prefix
api_tokens.token_hash
api_tokens.user_id
api_tokens.workspace_id
app_context_codes.action_id
app_context_codes.code_sha256
app_context_codes.consumed_at
app_context_codes.expires_at
app_context_codes.install_id
app_context_codes.item_id
app_context_codes.viewer_user_id
app_delivery_inflight.delivery_id
app_delivery_inflight.expires_at
app_delivery_inflight.install_id
@@ -54,6 +61,14 @@ app_installs.service_access
app_installs.state
app_installs.updated_at
app_installs.workspace_id
app_item_actions.action_key
app_item_actions.collection_ids
app_item_actions.created_at
app_item_actions.id
app_item_actions.install_id
app_item_actions.label
app_item_actions.path
app_item_actions.updated_at
app_token_bindings.auth_kind
app_token_bindings.client_id
app_token_bindings.created_at
@@ -0,0 +1,27 @@
-- Migration 099: app item actions and context codes (SPEC-6 U11, TASK-3414).
-- Mirrors SQLite migration 125; see it for the rationale.
CREATE TABLE IF NOT EXISTS app_item_actions (
id TEXT PRIMARY KEY,
install_id TEXT NOT NULL REFERENCES app_installs(id) ON DELETE CASCADE,
action_key TEXT NOT NULL,
label TEXT NOT NULL,
path TEXT NOT NULL,
collection_ids TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (install_id, action_key)
);
CREATE TABLE IF NOT EXISTS app_context_codes (
code_sha256 TEXT PRIMARY KEY,
install_id TEXT NOT NULL REFERENCES app_installs(id) ON DELETE CASCADE,
auth_epoch BIGINT NOT NULL,
item_id TEXT NOT NULL,
action_id TEXT NOT NULL,
action_revision INTEGER NOT NULL,
viewer_user_id TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_app_context_codes_expiry ON app_context_codes (expires_at);
+6 -2
View File
@@ -38,6 +38,10 @@ func TestTask3397_UninstallAppTxIsAllOrNothing(t *testing.T) {
VALUES (?, ?, 'https://portal.example/h', 's', '[]', ?, ?, ?, 0, ?)`), newID(), f.ws.ID, f.s.dialect.BoolToInt(true), now(), now(), f.installID); err != nil {
t.Fatal(err)
}
if _, err := htx.Exec(f.s.q(`INSERT INTO app_item_actions (id, install_id, action_key, label, path, collection_ids, revision, active, created_at, updated_at)
VALUES (?, ?, 'open', 'Open', '/t', '[]', 1, ?, ?, ?)`), newID(), f.installID, f.s.dialect.BoolToInt(true), now(), now()); err != nil {
t.Fatal(err)
}
if err := htx.Commit(); err != nil {
t.Fatal(err)
}
@@ -45,7 +49,7 @@ func TestTask3397_UninstallAppTxIsAllOrNothing(t *testing.T) {
t.Fatal(err)
}
tables := []string{"app_installs", "oauth_clients", "oauth_access_tokens", "app_token_bindings", "oauth_connections",
"workspace_members", "member_collection_access", "users", "sessions", "api_tokens", "webhooks"}
"workspace_members", "member_collection_access", "users", "sessions", "api_tokens", "webhooks", "app_item_actions"}
snapshot := func() map[string]string {
out := map[string]string{}
for _, tbl := range tables {
@@ -67,7 +71,7 @@ func TestTask3397_UninstallAppTxIsAllOrNothing(t *testing.T) {
}
before := snapshot()
boom := errors.New("injected")
for _, at := range []string{"client", "webhook", "membership", "bot", "state"} {
for _, at := range []string{"client", "webhook", "actions", "membership", "bot", "state"} {
uninstallHookAfterStep = func(step string) error {
if step == at {
return boom
+218
View File
@@ -0,0 +1,218 @@
package store
import (
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-3414 (SPEC-6 U11): every mutation that can invalidate a context code
// waits for a redeem holding the §6 locks (install, item, action), and a
// redeem that runs after it is refused. Deterministic: the redeem pauses
// with its locks held while the mutation is started.
type u11StoreFix struct {
task3408Fix
item *models.Item
actionID string
}
func u11StoreFixture(t *testing.T, installID string) u11StoreFix {
t.Helper()
base := task3408Fixture(t, installID)
f := u11StoreFix{task3408Fix: base}
if _, err := f.s.db.Exec(f.s.q(`UPDATE app_installs SET manifest = ? WHERE id = ?`), `{"title":"Portal","base_url":"https://portal.example"}`, installID); err != nil {
t.Fatal(err)
}
tx, err := f.s.db.Begin()
if err != nil {
t.Fatal(err)
}
if err := f.s.syncAppItemActionsTx(tx, f.ws.ID, installID, []AppActionSpec{{Key: "open", Label: "Open", Path: "/t", CollectionSlugs: []string{f.companion.Slug}}}); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := f.s.db.QueryRow(f.s.q(`SELECT id FROM app_item_actions WHERE install_id = ? AND action_key = 'open'`), installID).Scan(&f.actionID); err != nil {
t.Fatal(err)
}
if f.item, err = f.s.CreateItem(f.ws.ID, f.companion.ID, models.ItemCreate{Title: "Ticket", Fields: `{}`}); err != nil {
t.Fatal(err)
}
return f
}
func allVisible(Queryer, *models.Item, string) (bool, error) { return true, nil }
func (f u11StoreFix) epoch(t *testing.T) int64 {
t.Helper()
var e int64
if err := f.s.db.QueryRow(f.s.q(`SELECT auth_epoch FROM app_installs WHERE id = ?`), f.installID).Scan(&e); err != nil {
t.Fatal(err)
}
return e
}
// raceRedeem mints a code, then redeems it while mutate runs from inside the
// redeem's locks. mutate must still be blocked when the redeem's pause ends.
func (f u11StoreFix) raceRedeem(t *testing.T, mutate func() error) (*RedeemedContext, error) {
t.Helper()
minted, err := f.s.MintContextCode(f.ws.ID, f.installID, "open", f.item.ID, "viewer-1", allVisible)
if err != nil {
t.Fatalf("mint: %v", err)
}
done := make(chan error, 1)
var finishedDuringRedeem bool
redeemContextHook = func() {
go func() { done <- mutate() }()
select {
case err := <-done:
finishedDuringRedeem = true
done <- err
case <-time.After(400 * time.Millisecond):
}
}
t.Cleanup(func() { redeemContextHook = nil })
red, err := f.s.RedeemContextCode(f.installID, f.epoch(t), minted.Code, allVisible)
redeemContextHook = nil
if mErr := <-done; mErr != nil {
t.Fatalf("mutation: %v", mErr)
}
if finishedDuringRedeem {
t.Fatal("the mutation completed while the redeem held its locks")
}
return red, err
}
func TestTask3414_InvalidatingMutationsWaitForARedeem(t *testing.T) {
for _, tc := range []struct {
name string
mutate func(f u11StoreFix) error
}{
{"disable", func(f u11StoreFix) error {
return f.s.BeginInstallTeardown(f.ws.ID, f.installID, TeardownDisable)
}},
{"upgrade changes the action", func(f u11StoreFix) error {
tx, err := f.s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := f.s.lockInstallTx(tx, f.ws.ID, f.installID); err != nil {
return err
}
if err := f.s.syncAppItemActionsTx(tx, f.ws.ID, f.installID, []AppActionSpec{{Key: "open", Label: "Open v2", Path: "/t", CollectionSlugs: []string{f.companion.Slug}}}); err != nil {
return err
}
return tx.Commit()
}},
{"item moved", func(f u11StoreFix) error {
_, err := f.s.MoveItem(f.item.ID, f.other.ID, `{}`)
return err
}},
{"item deleted", func(f u11StoreFix) error {
return f.s.DeleteItem(f.item.ID)
}},
} {
t.Run(tc.name, func(t *testing.T) {
f := u11StoreFixture(t, "inst-race-"+tc.name[:4])
red, err := f.raceRedeem(t, func() error { return tc.mutate(f) })
// The redeem ran first: it saw the pre-mutation state.
if err != nil || red == nil || red.Item.ID != f.item.ID {
t.Fatalf("redeem ahead of the mutation: %+v %v", red, err)
}
// After the mutation, a fresh code is refused.
if _, err := f.s.MintContextCode(f.ws.ID, f.installID, "open", f.item.ID, "viewer-1", allVisible); err == nil {
// A move to a non-companion, delete, or disable refuses the
// mint; the action change keeps it mintable at the new
// revision, so redeem an OLD code instead below.
if tc.name != "upgrade changes the action" {
t.Fatalf("mint after %s was accepted", tc.name)
}
}
})
}
}
// A code minted before an action change is refused after it.
func TestTask3414_CodeFromBeforeAnUpgradeIsRefused(t *testing.T) {
f := u11StoreFixture(t, "inst-oldcode")
minted, err := f.s.MintContextCode(f.ws.ID, f.installID, "open", f.item.ID, "viewer-1", allVisible)
if err != nil {
t.Fatal(err)
}
tx, err := f.s.db.Begin()
if err != nil {
t.Fatal(err)
}
if err := f.s.syncAppItemActionsTx(tx, f.ws.ID, f.installID, []AppActionSpec{{Key: "open", Label: "Open v2", Path: "/t", CollectionSlugs: []string{f.companion.Slug}}}); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if _, err := f.s.RedeemContextCode(f.installID, f.epoch(t), minted.Code, allVisible); err != ErrContextCodeRefused {
t.Fatalf("redeem of a pre-upgrade code: %v", err)
}
}
// The backfill re-decides under the install lock: an install that is no
// longer active or inactive gets nothing, even if the work list said so.
func TestTask3414_BackfillSkipsAnUninstalledInstall(t *testing.T) {
f := u11StoreFixture(t, "inst-bf")
if _, err := f.s.db.Exec(f.s.q(`DELETE FROM app_item_actions WHERE install_id = ?`), f.installID); err != nil {
t.Fatal(err)
}
if _, err := f.s.db.Exec(f.s.q(`UPDATE app_installs SET state = 'uninstalled' WHERE id = ?`), f.installID); err != nil {
t.Fatal(err)
}
specs := func(string) ([]AppActionSpec, error) {
return []AppActionSpec{{Key: "open", Label: "Open", Path: "/t", CollectionSlugs: []string{f.companion.Slug}}}, nil
}
if err := f.s.EnsureAppItemActions(f.ws.ID, f.installID, specs); err != nil {
t.Fatal(err)
}
var n int
if err := f.s.db.QueryRow(f.s.q(`SELECT COUNT(*) FROM app_item_actions WHERE install_id = ?`), f.installID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("%d action rows backfilled for an uninstalled install", n)
}
}
// Store-level guards the HTTP routing happens to shadow: a mint for another
// workspace than the install's, and a redeem in a soft-deleted workspace.
func TestTask3414_StoreGuardsWorkspace(t *testing.T) {
f := u11StoreFixture(t, "inst-wsg")
if _, err := f.s.MintContextCode("some-other-workspace", f.installID, "open", f.item.ID, "viewer-1", allVisible); err != ErrContextCodeRefused {
t.Fatalf("mint for another workspace: %v", err)
}
minted, err := f.s.MintContextCode(f.ws.ID, f.installID, "open", f.item.ID, "viewer-1", allVisible)
if err != nil {
t.Fatal(err)
}
if _, err := f.s.db.Exec(f.s.q(`UPDATE workspaces SET deleted_at = ? WHERE id = ?`), now(), f.ws.ID); err != nil {
t.Fatal(err)
}
if _, err := f.s.RedeemContextCode(f.installID, f.epoch(t), minted.Code, allVisible); err != ErrContextCodeRefused {
t.Fatalf("redeem in a deleted workspace: %v", err)
}
}
// BurnContextCode consumes only the named install's code.
func TestTask3414_BurnIsScopedToTheInstall(t *testing.T) {
f := u11StoreFixture(t, "inst-burn")
minted, err := f.s.MintContextCode(f.ws.ID, f.installID, "open", f.item.ID, "viewer-1", allVisible)
if err != nil {
t.Fatal(err)
}
if err := f.s.BurnContextCode("another-install", minted.Code); err != nil {
t.Fatal(err)
}
if _, err := f.s.RedeemContextCode(f.installID, f.epoch(t), minted.Code, allVisible); err != nil {
t.Fatalf("another install's burn consumed the code: %v", err)
}
}
+19 -1
View File
@@ -86,7 +86,8 @@ import type {
ConnectedApp,
AppGrant,
ClaimCodeResponse,
ImportArtifactResult
ImportArtifactResult,
ItemAppAction
} from '$lib/types';
import { reportWorkspaceWrite } from './workspaceWrites';
import { noteServerDate } from './serverClock';
@@ -2038,6 +2039,23 @@ export const api = {
method: 'DELETE'
}),
/**
* The installed apps' item actions offered on this item, for the item
* pane's link-out button (SPEC-6 U11, TASK-3414). Shape frozen with U9.
*/
appActions: (ws: string, itemSlug: string) =>
request<ItemAppAction[]>(`/workspaces/${ws}/items/${itemSlug}/app-actions`),
/**
* Mint a single-use context code for an item action and get the URL
* to open (in a new tab, noopener). Every refusal is the same 404.
*/
mintAppAction: (ws: string, itemSlug: string, installId: string, actionKey: string) =>
request<{url: string}>(
`/workspaces/${ws}/items/${itemSlug}/app-actions/${encodeURIComponent(installId)}/${encodeURIComponent(actionKey)}`,
{method: 'POST'}
),
/** Check if an item is starred by the current user */
starStatus: (ws: string, itemSlug: string) =>
request<{starred: boolean}>(`/workspaces/${ws}/items/${itemSlug}/star`),
+11
View File
@@ -2857,3 +2857,14 @@ export interface ClientWriteStamp {
tab: string;
n: number;
}
/**
* An installed app's item action offered on an item (SPEC-6 U11, TASK-3414):
* GET /workspaces/{ws}/items/{ref}/app-actions. Shape frozen with U9.
*/
export interface ItemAppAction {
install_id: string;
app_title: string;
action_key: string;
label: string;
}