diff --git a/cmd/pad/cmd_server.go b/cmd/pad/cmd_server.go index 175348d1c..9c8107bdc 100644 --- a/cmd/pad/cmd_server.go +++ b/cmd/pad/cmd_server.go @@ -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 diff --git a/internal/server/app_api.go b/internal/server/app_api.go index d5a0a0069..2f14bb259 100644 --- a/internal/server/app_api.go +++ b/internal/server/app_api.go @@ -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 diff --git a/internal/server/app_item_actions.go b/internal/server/app_item_actions.go new file mode 100644 index 000000000..2b90777e9 --- /dev/null +++ b/internal/server/app_item_actions.go @@ -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]}) +} diff --git a/internal/server/app_pending_sweep.go b/internal/server/app_pending_sweep.go index d4489e67d..c3df1118c 100644 --- a/internal/server/app_pending_sweep.go +++ b/internal/server/app_pending_sweep.go @@ -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) + } } diff --git a/internal/server/handlers_apps_provision.go b/internal/server/handlers_apps_provision.go index 6f00a9528..2cb348fc7 100644 --- a/internal/server/handlers_apps_provision.go +++ b/internal/server/handlers_apps_provision.go @@ -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 } diff --git a/internal/server/handlers_apps_upgrade.go b/internal/server/handlers_apps_upgrade.go index 1e5341095..0da9eed5c 100644 --- a/internal/server/handlers_apps_upgrade.go +++ b/internal/server/handlers_apps_upgrade.go @@ -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), diff --git a/internal/server/server.go b/internal/server/server.go index 4233f19f5..313394986 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/task3401_app_api_test.go b/internal/server/task3401_app_api_test.go index 62bc87090..eda89f14d 100644 --- a/internal/server/task3401_app_api_test.go +++ b/internal/server/task3401_app_api_test.go @@ -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)) } } diff --git a/internal/server/task3414_item_actions_test.go b/internal/server/task3414_item_actions_test.go new file mode 100644 index 000000000..24ddc048e --- /dev/null +++ b/internal/server/task3414_item_actions_test.go @@ -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") + } + }) + } +} diff --git a/internal/store/app_item_actions.go b/internal/store/app_item_actions.go new file mode 100644 index 000000000..e749947a8 --- /dev/null +++ b/internal/store/app_item_actions.go @@ -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 +} diff --git a/internal/store/app_lifecycle.go b/internal/store/app_lifecycle.go index 9834706d1..31238d336 100644 --- a/internal/store/app_lifecycle.go +++ b/internal/store/app_lifecycle.go @@ -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 { diff --git a/internal/store/app_provision.go b/internal/store/app_provision.go index 08a3a572d..4501e543e 100644 --- a/internal/store/app_provision.go +++ b/internal/store/app_provision.go @@ -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). diff --git a/internal/store/app_upgrade.go b/internal/store/app_upgrade.go index deb1a0555..33a453650 100644 --- a/internal/store/app_upgrade.go +++ b/internal/store/app_upgrade.go @@ -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. diff --git a/internal/store/grants.go b/internal/store/grants.go index 24ea23dd5..e6883fcb2 100644 --- a/internal/store/grants.go +++ b/internal/store/grants.go @@ -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 diff --git a/internal/store/migrations/125_app_item_actions.sql b/internal/store/migrations/125_app_item_actions.sql new file mode 100644 index 000000000..9a89b72a4 --- /dev/null +++ b/internal/store/migrations/125_app_item_actions.sql @@ -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); diff --git a/internal/store/nul_unprotected_baseline.txt b/internal/store/nul_unprotected_baseline.txt index 90d9017a5..3607fc73b 100644 --- a/internal/store/nul_unprotected_baseline.txt +++ b/internal/store/nul_unprotected_baseline.txt @@ -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 diff --git a/internal/store/pgmigrations/099_app_item_actions.sql b/internal/store/pgmigrations/099_app_item_actions.sql new file mode 100644 index 000000000..ca97854c8 --- /dev/null +++ b/internal/store/pgmigrations/099_app_item_actions.sql @@ -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); diff --git a/internal/store/task3397_lifecycle_test.go b/internal/store/task3397_lifecycle_test.go index e4734abe4..d6288ad9a 100644 --- a/internal/store/task3397_lifecycle_test.go +++ b/internal/store/task3397_lifecycle_test.go @@ -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 diff --git a/internal/store/task3414_redeem_race_test.go b/internal/store/task3414_redeem_race_test.go new file mode 100644 index 000000000..ff097f4ad --- /dev/null +++ b/internal/store/task3414_redeem_race_test.go @@ -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) + } +} diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 5a274fd7b..fb30e329e 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -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(`/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`), diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 6d19ffa12..6333469d7 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -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; +}