mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +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)
503 lines
18 KiB
Go
503 lines
18 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// App install lifecycle: two-phase disable and rotate, re-enable, and
|
|
// uninstall (SPEC-6 U8c, DOC-3371 §2 and §8; TASK-3397).
|
|
//
|
|
// Phase 1 (BeginInstallTeardown) holds the install row FOR UPDATE, moves the
|
|
// install to its in-between state, bumps auth_epoch and revokes every grant
|
|
// the install's client holds. App mutations (FencedTx) and the issuance
|
|
// barrier take the same row with a shared lock, so nothing admitted before
|
|
// phase 1 is still deciding when it commits, and nothing is admitted after
|
|
// it. Then the delivery drain, then phase 2 in its own transaction, which
|
|
// re-takes the row and requires the phase-1 state: a crash between the two
|
|
// leaves the install in that state, and repeating the owner's call resumes.
|
|
//
|
|
// Lock order: phase 1 and phase 2 of disable and rotate take the install row
|
|
// first, then the client's grant tables and the code rows; the barrier
|
|
// (install row FOR SHARE, then token inserts), FencedTx (install row FOR SHARE
|
|
// first) and redeem (install row, then the code row) agree. UninstallAppTx
|
|
// alone takes the bot's membership and users rows BEFORE the install row, the
|
|
// order account deletion's bot purge takes them (see UninstallAppTx).
|
|
|
|
// Install states (the CHECK in migration 112).
|
|
const (
|
|
InstallActive = "active"
|
|
InstallDisabling = "disabling"
|
|
InstallInactive = "inactive"
|
|
InstallUninstalling = "uninstalling"
|
|
InstallUninstalled = "uninstalled"
|
|
)
|
|
|
|
// InstallStateError: the install is not in a state the operation accepts.
|
|
type InstallStateError struct{ State string }
|
|
|
|
func (e *InstallStateError) Error() string { return "install is " + e.State }
|
|
|
|
// ErrInstallNotFound: no such install in that workspace.
|
|
var ErrInstallNotFound = errors.New("install not found")
|
|
|
|
// TeardownKind names the operation phase 1 starts.
|
|
type TeardownKind int
|
|
|
|
const (
|
|
TeardownDisable TeardownKind = iota
|
|
TeardownRotate
|
|
TeardownUninstall
|
|
)
|
|
|
|
func (k TeardownKind) phase1State() string {
|
|
if k == TeardownUninstall {
|
|
return InstallUninstalling
|
|
}
|
|
return InstallDisabling
|
|
}
|
|
|
|
func (k TeardownKind) allowedFrom(state string) bool {
|
|
switch k {
|
|
case TeardownUninstall:
|
|
return state == InstallActive || state == InstallInactive || state == InstallDisabling
|
|
default:
|
|
return state == InstallActive
|
|
}
|
|
}
|
|
|
|
// lockInstallTx reads an install of workspaceID FOR UPDATE (Postgres; SQLite
|
|
// is BEGIN IMMEDIATE) and returns its state.
|
|
func (s *Store) lockInstallTx(tx *sql.Tx, workspaceID, installID string) (string, error) {
|
|
q := `SELECT state FROM app_installs WHERE id = ? AND workspace_id = ?`
|
|
if s.dialect.Driver() == DriverPostgres {
|
|
q += ` FOR UPDATE`
|
|
}
|
|
var state string
|
|
err := tx.QueryRow(s.q(q), installID, workspaceID).Scan(&state)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", ErrInstallNotFound
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("lock install: %w", err)
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
func (s *Store) setInstallStateTx(tx *sql.Tx, installID, state string, bumpEpoch bool) error {
|
|
q := `UPDATE app_installs SET state = ?, updated_at = ? WHERE id = ?`
|
|
if bumpEpoch {
|
|
q = `UPDATE app_installs SET state = ?, updated_at = ?, auth_epoch = auth_epoch + 1 WHERE id = ?`
|
|
}
|
|
if _, err := tx.Exec(s.q(q), state, now(), installID); err != nil {
|
|
return fmt.Errorf("set install state: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BeginInstallTeardown is phase 1. It is a no-op when the install is already
|
|
// in the phase-1 state for this kind (a resumed call): the epoch was bumped
|
|
// and the grants revoked by the call that got it there.
|
|
func (s *Store) BeginInstallTeardown(workspaceID, installID string, kind TeardownKind) 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
|
|
}
|
|
target := kind.phase1State()
|
|
if state == target {
|
|
return nil
|
|
}
|
|
if !kind.allowedFrom(state) {
|
|
return &InstallStateError{State: state}
|
|
}
|
|
// The epoch bump under this lock is what ends in-flight grants: each
|
|
// carries the epoch it read before client auth, and the barrier refuses
|
|
// a stale one (the U5a contract).
|
|
if err := s.setInstallStateTx(tx, installID, target, true); err != nil {
|
|
return err
|
|
}
|
|
if err := s.RevokeInstallClientGrantsTx(tx, installID); err != nil && !errors.Is(err, ErrNoInstallClient) {
|
|
return err
|
|
}
|
|
// Every unconsumed install code dies too: redeeming one would mint a
|
|
// fresh working secret, which rotate and disable exist to end (codex r1).
|
|
if _, err := tx.Exec(s.q(`DELETE FROM app_install_codes WHERE install_id = ? AND consumed_at IS NULL`), installID); err != nil {
|
|
return fmt.Errorf("revoke install codes: %w", err)
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// DrainInstallDeliveries is the wait between the phases: until the install
|
|
// has no unexpired in-flight webhook delivery row, in database time
|
|
// (DOC-3371 §5, TASK-3408 U10b). Phase 1 already refuses admission, so the
|
|
// set only shrinks. Phase 2 must not run before this returns nil.
|
|
//
|
|
// It gives up after appDeliveryDrainBound with ErrDrainTimeout: no row can
|
|
// outlive its 12 s expiry, so a longer wait means something is wrong with
|
|
// time, and the caller must not finish the phase. A cancelled ctx (the
|
|
// owner's request went away) stops the wait the same way; a repeated call
|
|
// resumes it, as the phases are resumable.
|
|
func (s *Store) DrainInstallDeliveries(ctx context.Context, installID string) error {
|
|
deadline := time.NewTimer(appDeliveryDrainBound)
|
|
defer deadline.Stop()
|
|
for {
|
|
n, err := s.liveAppDeliveries(installID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == 0 {
|
|
return nil
|
|
}
|
|
poll := time.NewTimer(appDeliveryDrainPoll)
|
|
select {
|
|
case <-ctx.Done():
|
|
poll.Stop()
|
|
return ctx.Err()
|
|
case <-deadline.C:
|
|
poll.Stop()
|
|
return ErrDrainTimeout
|
|
case <-poll.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// ErrDrainTimeout: an install still had in-flight deliveries after the
|
|
// drain's bound. The phase is not finished; repeating the call resumes it.
|
|
var ErrDrainTimeout = errors.New("app deliveries still in flight")
|
|
|
|
// FinishDisable is disable's phase 2: disabling -> inactive.
|
|
//
|
|
// 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). 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 {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
state, err := s.lockInstallTx(tx, workspaceID, installID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if state == InstallInactive {
|
|
return nil
|
|
}
|
|
if state != InstallDisabling {
|
|
return &InstallStateError{State: state}
|
|
}
|
|
if err := s.setInstallStateTx(tx, installID, InstallInactive, false); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// FinishRotate is rotate's phase 2: disabling -> active with a new client
|
|
// secret (discarded: the app receives one through redeem) and a new install
|
|
// code, returned once.
|
|
func (s *Store) FinishRotate(workspaceID, installID string) (string, time.Time, error) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
state, err := s.lockInstallTx(tx, workspaceID, installID)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
if state != InstallDisabling {
|
|
return "", time.Time{}, &InstallStateError{State: state}
|
|
}
|
|
// A second bump: a token request that read the phase-1 epoch while the
|
|
// install was disabling, and authenticated with the OLD secret, would
|
|
// otherwise commit a live token once this makes the install active
|
|
// (codex r1). Its grant carries the phase-1 epoch, which this ends.
|
|
if err := s.setInstallStateTx(tx, installID, InstallActive, true); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
secret, err := s.RotateInstallClientSecretTx(tx, installID)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
discardSecret(&secret)
|
|
// The webhook's secret is replaced and the hook HELD again, like the
|
|
// client secret: the old one may be what leaked, and the app gets the
|
|
// new one only by redeeming this code (TASK-3408 U10b).
|
|
if err := s.holdAppWebhookTx(tx, installID); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
code, exp, err := s.mintInstallCodeTx(tx, installID)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
return code, exp, nil
|
|
}
|
|
|
|
// ReenableInstall: inactive -> active. The epoch is NEVER moved back (DOC-3371
|
|
// §2): every token issued before the disable stays dead, and the app obtains
|
|
// fresh ones.
|
|
func (s *Store) ReenableInstall(workspaceID, installID string) 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 {
|
|
return nil
|
|
}
|
|
if state != InstallInactive {
|
|
return &InstallStateError{State: state}
|
|
}
|
|
if err := s.setInstallStateTx(tx, installID, InstallActive, false); err != nil {
|
|
return err
|
|
}
|
|
// Events from the disabled period are never delivered after it, even
|
|
// when an owner hook's failure kept them pending (codex r5 on U10b).
|
|
if _, err := tx.Exec(s.q(`UPDATE webhooks SET deliver_from = ?, updated_at = ? WHERE app_install_id = ?`), now(), now(), installID); err != nil {
|
|
return fmt.Errorf("re-enable: webhook: %w", err)
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// uninstallHookAfterStep, when set by a test, runs after each UninstallAppTx
|
|
// step; a non-nil return aborts the transaction (the atomicity test).
|
|
var uninstallHookAfterStep func(step string) error
|
|
|
|
// UninstallAppTx is the uninstall's phase 2 (DOC-3371 §8 step 3): ONE
|
|
// transaction, child-first, uninstalling -> uninstalled.
|
|
//
|
|
// 1. the install client: its oauth_connections by request_id first, then
|
|
// its bindings and grants, then the client row (U5a's
|
|
// 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 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'.
|
|
//
|
|
// The bot's users row is KEPT, disabled, and the install row is never
|
|
// deleted: items, comments, versions, links and events keep via_app and the
|
|
// bot's user id, and display resolves the app's name from the tombstone.
|
|
// This differs on purpose from the account-deletion purge
|
|
// (purgeAppPrincipalsOfOwnedWorkspacesTx), which ERASES bots because their
|
|
// workspace is itself going away (lead ruling, day 86).
|
|
func (s *Store) UninstallAppTx(workspaceID, installID string) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
// Lock order (Postgres): the bot's membership row, then its users row,
|
|
// then the install row. Account deletion purging this bot takes them in
|
|
// that order (membership, user, then the install row through the
|
|
// bot_user_id FK action); taking the install row first deadlocked with it
|
|
// (codex r1). The users row is FOR NO KEY UPDATE, which does not conflict
|
|
// with the FK KEY SHARE a fenced app write takes on it while holding the
|
|
// install row FOR SHARE. SQLite: BEGIN IMMEDIATE.
|
|
if s.dialect.Driver() == DriverPostgres {
|
|
var pre sql.NullString
|
|
if err := tx.QueryRow(s.q(`SELECT bot_user_id FROM app_installs WHERE id = ? AND workspace_id = ?`), installID, workspaceID).Scan(&pre); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("uninstall: read bot: %w", err)
|
|
}
|
|
if pre.Valid && pre.String != "" {
|
|
if err := lockRowsForShare(tx, s.q(`SELECT user_id FROM workspace_members WHERE workspace_id = ? AND user_id = ? FOR UPDATE`), workspaceID, pre.String); err != nil {
|
|
return fmt.Errorf("uninstall: lock bot membership: %w", err)
|
|
}
|
|
if err := lockRowsForShare(tx, s.q(`SELECT id FROM users WHERE id = ? FOR NO KEY UPDATE`), pre.String); err != nil {
|
|
return fmt.Errorf("uninstall: lock bot: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if uninstallHookAfterStep != nil {
|
|
if err := uninstallHookAfterStep("bot-locks"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
state, err := s.lockInstallTx(tx, workspaceID, installID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if state == InstallUninstalled {
|
|
return nil
|
|
}
|
|
if state != InstallUninstalling {
|
|
return &InstallStateError{State: state}
|
|
}
|
|
step := func(name string) error {
|
|
if uninstallHookAfterStep != nil {
|
|
return uninstallHookAfterStep(name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := s.DeleteInstallClientTx(tx, installID); err != nil && !errors.Is(err, ErrNoInstallClient) {
|
|
return fmt.Errorf("uninstall: client: %w", err)
|
|
}
|
|
if err := step("client"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The hook: the tombstone install row keeps no way to be called.
|
|
if err := s.deleteAppWebhookTx(tx, installID); err != nil {
|
|
return fmt.Errorf("uninstall: %w", err)
|
|
}
|
|
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 {
|
|
return fmt.Errorf("uninstall: read bot: %w", err)
|
|
}
|
|
if bot.Valid && bot.String != "" {
|
|
if _, err := tx.Exec(s.q(`DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?`), workspaceID, bot.String); err != nil {
|
|
return fmt.Errorf("uninstall: bot membership: %w", err)
|
|
}
|
|
if err := step("membership"); err != nil {
|
|
return err
|
|
}
|
|
if err := s.disableUserAndRevokeAccessTx(tx, bot.String); err != nil {
|
|
return fmt.Errorf("uninstall: bot: %w", err)
|
|
}
|
|
if err := step("bot"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := s.setInstallStateTx(tx, installID, InstallUninstalled, false); err != nil {
|
|
return err
|
|
}
|
|
if err := step("state"); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// InstallState returns an install's state, for the HTTP layer's answers.
|
|
func (s *Store) InstallState(workspaceID, installID string) (string, error) {
|
|
var state string
|
|
err := s.db.QueryRow(s.q(`SELECT state FROM app_installs WHERE id = ? AND workspace_id = ?`), installID, workspaceID).Scan(&state)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", ErrInstallNotFound
|
|
}
|
|
return state, err
|
|
}
|
|
|
|
// WorkspaceInstall is one install as the owner's Apps list shows it (SPEC-6
|
|
// U9a, TASK-3413). AppName is the install's bot display name (the app's
|
|
// title), else its origin, as the console's app connections name it.
|
|
type WorkspaceInstall struct {
|
|
ID string
|
|
Origin string
|
|
AppName string
|
|
Version string
|
|
State string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// ListWorkspaceInstalls returns every install of the workspace, the
|
|
// uninstalled tombstones included, newest first.
|
|
func (s *Store) ListWorkspaceInstalls(workspaceID string) ([]WorkspaceInstall, error) {
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT i.id, i.origin, COALESCE(u.name, ''), COALESCE(i.manifest_version, ''), i.state, i.created_at, i.updated_at
|
|
FROM app_installs i
|
|
LEFT JOIN users u ON u.id = i.bot_user_id
|
|
WHERE i.workspace_id = ?
|
|
ORDER BY i.created_at DESC, i.id`), workspaceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list workspace installs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := []WorkspaceInstall{}
|
|
for rows.Next() {
|
|
var in WorkspaceInstall
|
|
var created, updated string
|
|
if err := rows.Scan(&in.ID, &in.Origin, &in.AppName, &in.Version, &in.State, &created, &updated); err != nil {
|
|
return nil, fmt.Errorf("scan workspace install: %w", err)
|
|
}
|
|
if in.AppName == "" {
|
|
in.AppName = in.Origin
|
|
}
|
|
in.CreatedAt, in.UpdatedAt = parseTime(created), parseTime(updated)
|
|
out = append(out, in)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// InstallArtifactItem is a live item provisioned from an app's companion pack
|
|
// (SPEC-6 U9b, TASK-3413): its id and the pack stamp, origin@version.
|
|
type InstallArtifactItem struct {
|
|
ItemID string
|
|
SourcePack string
|
|
}
|
|
|
|
// ListInstallArtifactItems returns the workspace's live items stamped with a
|
|
// pack from origin (source_pack = origin@version), oldest first. A prefix is
|
|
// compared with substr, not LIKE, so an origin's own characters never act as
|
|
// a pattern; its length is in CHARACTERS, which is what substr counts on
|
|
// both dialects (an origin's host is not punycoded, so it may be non-ASCII).
|
|
//
|
|
// The stamp names the app, not one install. The install conflict check
|
|
// allows one non-uninstalled install per origin in a workspace, so the only
|
|
// other install these rows can come from is an uninstalled one of the same
|
|
// app, whose items a reinstall is meant to show (codex U9b r1).
|
|
func (s *Store) ListInstallArtifactItems(workspaceID, origin string) ([]InstallArtifactItem, error) {
|
|
prefix := origin + "@"
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT id, source_pack FROM items
|
|
WHERE workspace_id = ? AND deleted_at IS NULL AND source_pack IS NOT NULL
|
|
AND substr(source_pack, 1, ?) = ?
|
|
ORDER BY created_at, id`), workspaceID, utf8.RuneCountInString(prefix), prefix)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list install artifact items: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := []InstallArtifactItem{}
|
|
for rows.Next() {
|
|
var a InstallArtifactItem
|
|
if err := rows.Scan(&a.ItemID, &a.SourcePack); err != nil {
|
|
return nil, fmt.Errorf("scan install artifact item: %w", err)
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// InstallOrigin returns an install's origin.
|
|
func (s *Store) InstallOrigin(workspaceID, installID string) (string, error) {
|
|
var origin string
|
|
err := s.db.QueryRow(s.q(`SELECT origin FROM app_installs WHERE id = ? AND workspace_id = ?`), installID, workspaceID).Scan(&origin)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", ErrInstallNotFound
|
|
}
|
|
return origin, err
|
|
}
|