Files
pad/internal/server/app_webhooks.go
T
xarmian 9ae4a0be45 feat(apps): upgrade review and draft activation in Settings → Apps (TASK-3413, SPEC-6 U9b) (#1789)
* feat(apps): the install view lists the items its pack provisioned (source_pack = origin@version), for draft activation (TASK-3413, SPEC-6 U9b)

* refactor(apps): the per-artifact review is its own component, shared by install and upgrade (TASK-3413, U9b)

* feat(apps): the upgrade review (diff by review/auto, released companions, new drafts, the reviewed hash) and the drafts panel with per-item activation (TASK-3413, SPEC-6 U9b)

* test(apps): each upgrade diff line sits in exactly one group (TASK-3413, U9b mutant U2)

* test(apps): e2e for the upgrade review and draft activation through page.route (TASK-3413, U9b)

* fix(apps): Activate addresses the item by id, not slug; the origin prefix is compared in characters; the one-live-install-per-origin rule is documented (TASK-3413, U9b codex r1)

* fix(apps): Activate moves focus to its row and announces it; the draft list retries after a failure; the upgrade's loading line is a status (TASK-3413, U9b codex r2)

* fix(apps): activation moves focus only when it was lost, and to the panel heading when the row is gone (TASK-3413, U9b codex r3)

* test(apps): an app token, service or a delegated owner's, cannot activate its own draft artifact (§2 step 7; TASK-3413, U9b lead ruling)
2026-10-05 06:09:12 -04:00

206 lines
7.0 KiB
Go

package server
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/PerpetualSoftware/pad/internal/appmanifest"
"github.com/PerpetualSoftware/pad/internal/store"
)
// App-owned webhooks (SPEC-6 U10a, TASK-3408). The hook is created from the
// manifest at provisioning and upgrade and is HELD until a redeem hands the
// app its signing secret; delivery (U10b) skips a held hook.
// appWebhookHeldNotice is what the owner sees while the hook waits for its
// secret. The reissue is POST .../apps/{installID}/install-code.
const appWebhookHeldNotice = "This app's webhook needs a new install code to activate: issue one and give it to the app."
// appWebhookSpec translates the manifest's events (by companion KEY) into the
// store's spec (by companion SLUG). nil when the manifest declares no events.
func appWebhookSpec(m *appmanifest.Manifest) *store.AppWebhookSpec {
if m == nil || len(m.Events) == 0 || m.WebhookURL == "" {
return nil
}
slugByKey := map[string]string{}
for _, c := range m.CompanionPack.Collections {
slugByKey[c.Key] = c.Slug
}
spec := &store.AppWebhookSpec{URL: m.WebhookURL}
for _, e := range m.Events {
ev := store.AppWebhookEvent{Name: e.Name}
for _, k := range e.Collections {
// The manifest validator refused a key that is not a companion's.
ev.CollectionSlugs = append(ev.CollectionSlugs, slugByKey[k])
}
spec.Events = append(spec.Events, ev)
}
return spec
}
// appWebhookSpecFromJSON is appWebhookSpec over a stored manifest.
func appWebhookSpecFromJSON(manifestJSON string) (*store.AppWebhookSpec, error) {
var m appmanifest.Manifest
if err := json.Unmarshal([]byte(manifestJSON), &m); err != nil {
return nil, err
}
return appWebhookSpec(&m), nil
}
// appWebhookView is the owner-facing hook status, with the held notice.
type appWebhookView struct {
URL string `json:"url"`
Status string `json:"status"`
Notice string `json:"notice,omitempty"`
// UndeliveredDropped counts deliveries dropped undelivered after 24 h.
UndeliveredDropped int64 `json:"undelivered_dropped"`
}
func (s *Server) appWebhookView(installID string) (*appWebhookView, error) {
st, err := s.store.GetAppWebhookStatus(installID)
if err != nil || st == nil {
return nil, err
}
v := &appWebhookView{URL: st.URL, Status: st.Status, UndeliveredDropped: st.Dropped}
if st.Status == store.AppWebhookAwaitingSecret {
v.Notice = appWebhookHeldNotice
}
return v, nil
}
// GET /workspaces/{ws}/apps/{installID}: the install's state and its hook.
func (s *Server) handleGetAppInstall(w http.ResponseWriter, r *http.Request) {
workspaceID, _, ok := s.requireAppsOwner(w, r)
if !ok {
return
}
installID := chi.URLParam(r, "installID")
state, err := s.store.InstallState(workspaceID, installID)
if err != nil {
s.writeInstallLifecycleError(w, err)
return
}
out := appInstallStateResponse{InstallID: installID, State: state}
if out.Webhook, err = s.appWebhookView(installID); err != nil {
writeInternalError(w, err)
return
}
if out.Artifacts, err = s.appInstallArtifacts(workspaceID, installID); err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// appInstallArtifacts lists the live items stamped with this install's
// origin (U9b, TASK-3413). Provisioning and upgrade stamp source_pack =
// origin@version; an install keeps no item list of its own.
func (s *Server) appInstallArtifacts(workspaceID, installID string) ([]appInstallArtifact, error) {
origin, err := s.store.InstallOrigin(workspaceID, installID)
if err != nil {
return nil, err
}
rows, err := s.store.ListInstallArtifactItems(workspaceID, origin)
if err != nil {
return nil, err
}
out := []appInstallArtifact{}
for _, row := range rows {
item, err := s.store.GetItem(row.ItemID)
if err != nil {
return nil, err
}
if item == nil {
continue // deleted between the two reads
}
out = append(out, appInstallArtifact{
ItemID: item.ID, Ref: item.Ref, Slug: item.Slug, Title: item.Title, CollectionSlug: item.CollectionSlug,
Status: itemStatus(item), Version: strings.TrimPrefix(row.SourcePack, origin+"@"),
})
}
return out, nil
}
// EnsureAppWebhooks backfills the hook of every active or inactive install
// provisioned before U10 whose manifest declares one. Each is created HELD:
// the app was never given a secret and gets one at its next redeem. Run at
// server start; idempotent (an install that has a hook is skipped under its
// row lock). A failure is logged per install and never stops the start.
func (s *Server) EnsureAppWebhooks(ctx context.Context) {
list, err := s.store.ListInstallsWithoutWebhook()
if err != nil {
slog.Error("apps: webhook backfill: list", "error", err)
return
}
for _, in := range list {
if ctx.Err() != nil {
return
}
if err := s.store.EnsureAppWebhook(in.WorkspaceID, in.InstallID, appWebhookSpecFromJSON); err != nil {
slog.Error("apps: webhook backfill", "install_id", in.InstallID, "error", err)
}
}
}
// appInstallListEntry is one row of the owner's Apps list (U9a, TASK-3413).
type appInstallListEntry struct {
InstallID string `json:"install_id"`
AppName string `json:"app_name"`
Origin string `json:"origin"`
Version string `json:"version,omitempty"`
State string `json:"state"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Webhook *appWebhookView `json:"webhook,omitempty"`
}
// appInstallListResponse answers GET /workspaces/{ws}/apps. Available is
// false when apps are off on this server, so the page can say "ask your
// admin" (Dave §11 Q1) instead of reading a 404 as an empty workspace.
type appInstallListResponse struct {
Available bool `json:"available"`
Cloud bool `json:"cloud"`
Installs []appInstallListEntry `json:"installs"`
}
// GET /workspaces/{ws}/apps: the workspace's installs, owner-only. Unlike
// the other install doors it answers while apps are off, with available
// false and no installs, so the owner sees why there is nothing to manage.
func (s *Server) handleListAppInstalls(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
out := appInstallListResponse{Available: s.appsAvailable(), Cloud: s.cloudMode, Installs: []appInstallListEntry{}}
if !out.Available {
writeJSON(w, http.StatusOK, out)
return
}
list, err := s.store.ListWorkspaceInstalls(workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
for _, in := range list {
e := appInstallListEntry{
InstallID: in.ID, AppName: in.AppName, Origin: in.Origin, Version: in.Version, State: in.State,
CreatedAt: in.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: in.UpdatedAt.UTC().Format(time.RFC3339),
}
if e.Webhook, err = s.appWebhookView(in.ID); err != nil {
writeInternalError(w, err)
return
}
out.Installs = append(out.Installs, e)
}
writeJSON(w, http.StatusOK, out)
}