mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
9ae4a0be45
* 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)
206 lines
7.0 KiB
Go
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)
|
|
}
|