@stacksjs/defaults 0.70.366 → 0.70.367

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +9 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +49 -1
  3. package/ai/skills/stacks-deploy/SKILL.md +18 -0
  4. package/ai/skills/stacks-desktop/SKILL.md +11 -2
  5. package/ai/skills/stacks-stx/SKILL.md +25 -0
  6. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +64 -44
  7. package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +26 -0
  8. package/app/Actions/Dashboard/Deployments/PreviewDeployment.ts +39 -0
  9. package/app/Actions/Dashboard/Deployments/PreviewDeploymentRollback.ts +22 -0
  10. package/app/Actions/Dashboard/Deployments/deployment-input.test.ts +12 -2
  11. package/app/Actions/Dashboard/Deployments/deployment-input.ts +5 -3
  12. package/app/Actions/Dashboard/Deployments/deployment-preview.test.ts +29 -0
  13. package/app/Actions/Dashboard/Deployments/deployment-preview.ts +41 -0
  14. package/app/Actions/Dashboard/Deployments/deployment-rollback.test.ts +16 -0
  15. package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +89 -0
  16. package/app/Actions/Dashboard/Operations/AuditIndexAction.ts +25 -0
  17. package/app/Actions/Dashboard/Operations/ChangeApprovalAction.ts +28 -0
  18. package/app/Actions/Dashboard/Operations/ChangeIndexAction.ts +32 -0
  19. package/app/Actions/Dashboard/Operations/IncidentIndexAction.ts +32 -0
  20. package/app/Actions/Dashboard/Operations/IncidentUpdateAction.ts +34 -0
  21. package/app/Actions/Dashboard/Operations/MigrationApplyAction.ts +30 -0
  22. package/app/Actions/Dashboard/Operations/MigrationIndexAction.ts +24 -0
  23. package/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +23 -0
  24. package/app/Actions/Dashboard/Operations/RecoveryDestinationStoreAction.ts +91 -0
  25. package/app/Actions/Dashboard/Operations/RecoveryDestinationTestAction.ts +34 -0
  26. package/app/Actions/Dashboard/Operations/RecoveryIndexAction.ts +58 -0
  27. package/app/Actions/Dashboard/Operations/RecoveryPolicyStoreAction.ts +90 -0
  28. package/app/Actions/Dashboard/Operations/RecoveryProtectAction.ts +32 -0
  29. package/app/Actions/Dashboard/Operations/RecoveryRestoreAction.ts +72 -0
  30. package/app/Actions/Dashboard/Operations/RecoveryRetentionAction.ts +17 -0
  31. package/app/Actions/Dashboard/Operations/RecoveryRunAction.ts +25 -0
  32. package/app/Actions/Dashboard/Operations/RecoveryVerifyAction.ts +24 -0
  33. package/app/Actions/Dashboard/Operations/SchedulerIndexAction.ts +34 -0
  34. package/app/Actions/Dashboard/Operations/SchedulerRunAction.ts +33 -0
  35. package/app/Actions/Dashboard/Operations/SchedulerToggleAction.ts +36 -0
  36. package/app/Actions/Dashboard/Operations/control-plane.ts +150 -0
  37. package/app/Actions/Dashboard/Operations/migration-operations.ts +80 -0
  38. package/app/Actions/Dashboard/Operations/operations-runtime.ts +11 -0
  39. package/app/Actions/Dashboard/Operations/recovery-input.test.ts +22 -0
  40. package/app/Actions/Dashboard/Operations/recovery-input.ts +33 -0
  41. package/app/Actions/Dashboard/Operations/recovery-runtime.ts +99 -0
  42. package/app/Actions/Dashboard/Operations/scheduler-operations.test.ts +23 -0
  43. package/app/Actions/Dashboard/Operations/scheduler-operations.ts +138 -0
  44. package/functions/deployments.ts +18 -0
  45. package/functions/operations.ts +361 -0
  46. package/ide/vscode/package.json +1 -1
  47. package/package.json +1 -1
  48. package/resources/components/Dashboard/Ci/CiDashboard.stx +4 -4
  49. package/resources/components/Dashboard/Deployments/DeploymentList.stx +147 -14
  50. package/resources/components/Dashboard/Deployments/DeploymentPreviewDialog.stx +118 -0
  51. package/resources/components/Dashboard/GlobalSearch.stx +87 -91
  52. package/resources/components/Dashboard/Operations/AuditDashboard.stx +16 -0
  53. package/resources/components/Dashboard/Operations/ChangeDashboard.stx +66 -0
  54. package/resources/components/Dashboard/Operations/IncidentDashboard.stx +39 -0
  55. package/resources/components/Dashboard/Operations/MigrationDashboard.stx +146 -0
  56. package/resources/components/Dashboard/Operations/OperationHistory.stx +61 -0
  57. package/resources/components/Dashboard/Operations/OperationsNavigation.stx +32 -0
  58. package/resources/components/Dashboard/Operations/RecoveryDashboard.stx +229 -0
  59. package/resources/components/Dashboard/Operations/RecoveryDestinationDialog.stx +126 -0
  60. package/resources/components/Dashboard/Operations/RecoveryJobList.stx +32 -0
  61. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +82 -0
  62. package/resources/components/Dashboard/Operations/RecoveryPolicyDialog.stx +155 -0
  63. package/resources/components/Dashboard/Operations/RecoveryRestoreDialog.stx +90 -0
  64. package/resources/components/Dashboard/Operations/SchedulerDashboard.stx +144 -0
  65. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +81 -0
  66. package/resources/components/Dashboard/Settings/SettingsDashboard.stx +45 -3
  67. package/resources/components/Dashboard/UI/Modal.stx +33 -8
  68. package/resources/functions/dashboard/sidebar.ts +1 -0
  69. package/routes/dashboard-api.ts +23 -0
  70. package/routes/dashboard.ts +1 -0
  71. package/views/dashboard/operations/audit.stx +5 -0
  72. package/views/dashboard/operations/changes.stx +5 -0
  73. package/views/dashboard/operations/incidents.stx +5 -0
  74. package/views/dashboard/operations/migrations.stx +8 -0
  75. package/views/dashboard/operations/recovery.stx +8 -0
  76. package/views/dashboard/operations/scheduler.stx +8 -0
  77. package/views/dashboard/stores/ci.ts +2 -2
@@ -0,0 +1,89 @@
1
+ import process from 'node:process'
2
+
3
+ export interface DeploymentRollbackInput {
4
+ environment: string
5
+ site?: string
6
+ release?: string
7
+ }
8
+
9
+ export class DeploymentRollbackError extends Error {
10
+ constructor(message: string) {
11
+ super(message)
12
+ this.name = 'DeploymentRollbackError'
13
+ }
14
+ }
15
+
16
+ function identifier(value: unknown, label: string, required = false): string {
17
+ const normalized = String(value || '').trim().toLowerCase()
18
+ if (!normalized && !required) return ''
19
+ if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(normalized))
20
+ throw new DeploymentRollbackError(`${label} must contain only letters, numbers, dots, underscores, and hyphens.`)
21
+ return normalized
22
+ }
23
+
24
+ export function deploymentRollbackInput(input: Record<string, unknown>): DeploymentRollbackInput {
25
+ return {
26
+ environment: identifier(input.environment || 'production', 'Environment', true),
27
+ site: identifier(input.site, 'Site') || undefined,
28
+ release: identifier(input.release || input.to, 'Release') || undefined,
29
+ }
30
+ }
31
+
32
+ function commandArgs(input: DeploymentRollbackInput, preview: boolean): string[] {
33
+ const args = ['deploy:rollback']
34
+ if (input.site) args.push(input.site)
35
+ args.push('--env', input.environment)
36
+ if (input.release) args.push('--to', input.release)
37
+ if (preview) args.push('--dry-run')
38
+ args.push('--no-interaction')
39
+ return args
40
+ }
41
+
42
+ function plainOutput(value: string): string {
43
+ return value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '').trim()
44
+ }
45
+
46
+ async function run(input: DeploymentRollbackInput, preview: boolean): Promise<string> {
47
+ const child = Bun.spawn([`${process.cwd()}/buddy`, ...commandArgs(input, preview)], {
48
+ cwd: process.cwd(),
49
+ env: { ...process.env, NO_COLOR: '1' },
50
+ stdout: 'pipe',
51
+ stderr: 'pipe',
52
+ })
53
+ const [stdout, stderr, exitCode] = await Promise.all([
54
+ new Response(child.stdout).text(),
55
+ new Response(child.stderr).text(),
56
+ child.exited,
57
+ ])
58
+ const output = plainOutput([stdout, stderr].filter(Boolean).join('\n'))
59
+ if (exitCode !== 0)
60
+ throw new DeploymentRollbackError(output || 'The rollback command failed.')
61
+ return output
62
+ }
63
+
64
+ export async function previewDeploymentRollback(input: DeploymentRollbackInput) {
65
+ const output = await run(input, true)
66
+ const revision = new Bun.CryptoHasher('sha256').update(JSON.stringify(input)).update(output).digest('hex')
67
+ return {
68
+ revision,
69
+ environment: input.environment,
70
+ site: input.site || null,
71
+ release: input.release || null,
72
+ target: input.release || 'previous preserved release',
73
+ output,
74
+ warnings: [
75
+ 'The active release pointer and service runtime will change.',
76
+ 'Database migrations are not reversed automatically.',
77
+ ],
78
+ }
79
+ }
80
+
81
+ export async function executeDeploymentRollback(input: DeploymentRollbackInput, revision: string, confirmation: string) {
82
+ const preview = await previewDeploymentRollback(input)
83
+ if (preview.revision !== revision)
84
+ throw new DeploymentRollbackError('The rollback preview changed. Review the current preview before continuing.')
85
+ if (confirmation !== `rollback ${input.environment}`)
86
+ throw new DeploymentRollbackError(`Type rollback ${input.environment} to confirm this recovery action.`)
87
+ const output = await run(input, false)
88
+ return { message: `Rollback for ${input.site || input.environment} completed.`, output }
89
+ }
@@ -0,0 +1,25 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { operationsControlPlane } from './control-plane'
4
+
5
+ export default new Action({
6
+ name: 'AuditIndexAction',
7
+ description: 'Returns the append-only operator event stream and its correlated operations.',
8
+ method: 'GET',
9
+ apiResponse: true,
10
+ async handle(_request: RequestInstance) {
11
+ const controlPlane = operationsControlPlane()
12
+ const events = controlPlane.store.listEvents({ projectId: controlPlane.project.id, limit: 500 })
13
+ const operations = controlPlane.store.listOperations({ projectId: controlPlane.project.id, limit: 200 })
14
+ return {
15
+ events: events.map(event => ({ ...event, actorName: event.actorId ? controlPlane.store.getActor(event.actorId)?.displayName || 'Unknown operator' : 'System' })),
16
+ operations,
17
+ summary: {
18
+ events: events.length,
19
+ operators: new Set(events.map(event => event.actorId).filter(Boolean)).size,
20
+ failures: events.filter(event => event.level === 'error').length,
21
+ latestSequence: events.reduce((latest, event) => Math.max(latest, event.sequence), 0),
22
+ },
23
+ }
24
+ },
25
+ })
@@ -0,0 +1,28 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { dashboardOperator, operationsControlPlane } from './control-plane'
5
+ import { stringValue } from './recovery-input'
6
+ import { releaseStore } from './operations-runtime'
7
+
8
+ export default new Action({
9
+ name: 'ChangeApprovalAction',
10
+ description: 'Approves or rejects a release awaiting operator review.',
11
+ method: 'POST',
12
+ apiResponse: true,
13
+ async handle(request: RequestInstance) {
14
+ const body = request.all() as Record<string, unknown>
15
+ const decision = stringValue(body.decision)
16
+ if (!['approved', 'rejected'].includes(decision))
17
+ return response.json({ message: 'Decision must be approved or rejected.' }, 422)
18
+ const store = releaseStore()
19
+ const release = store.get(String(request.getParam('id') || ''))
20
+ if (!release || release.projectId !== operationsControlPlane().project.id)
21
+ return response.json({ message: 'Release approval was not found.' }, 404)
22
+ if (release.status !== 'awaiting_approval')
23
+ return response.json({ message: 'This release is no longer awaiting approval.' }, 409)
24
+ const actor = await dashboardOperator(request)
25
+ const updated = store.approve(release.id, { actorId: actor.id, decision: decision as 'approved' | 'rejected', comment: stringValue(body.comment) || undefined })
26
+ return { success: true, release: updated, approvals: store.approvals(release.id) }
27
+ },
28
+ })
@@ -0,0 +1,32 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { operationsControlPlane, operationsEnvironment, recentOperatorOperations } from './control-plane'
4
+ import { migrationPlan } from './migration-operations'
5
+ import { releaseStore } from './operations-runtime'
6
+
7
+ export default new Action({
8
+ name: 'ChangeIndexAction',
9
+ description: 'Returns one review surface for pending schema, deployment, and release changes.',
10
+ method: 'GET',
11
+ apiResponse: true,
12
+ async handle(_request: RequestInstance) {
13
+ const controlPlane = operationsControlPlane()
14
+ const environment = operationsEnvironment(controlPlane)
15
+ const releases = releaseStore().list({ projectId: controlPlane.project.id, environmentId: environment?.id })
16
+ const pendingReleases = releases.filter(release => release.status === 'awaiting_approval')
17
+ const migrations = await migrationPlan()
18
+ return {
19
+ environment,
20
+ migrations,
21
+ pendingReleases: pendingReleases.map(release => ({ ...release, approvals: releaseStore().approvals(release.id) })),
22
+ activeOperations: controlPlane.store.listOperations({ projectId: controlPlane.project.id, limit: 100 }).filter(operation => ['queued', 'running'].includes(operation.state)),
23
+ recentOperations: recentOperatorOperations('dashboard.', 30),
24
+ summary: {
25
+ migrationChanges: migrations.summary.pending,
26
+ destructiveChanges: migrations.summary.destructive,
27
+ approvals: pendingReleases.length,
28
+ activeOperations: controlPlane.store.listOperations({ projectId: controlPlane.project.id, limit: 100 }).filter(operation => ['queued', 'running'].includes(operation.state)).length,
29
+ },
30
+ }
31
+ },
32
+ })
@@ -0,0 +1,32 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { operationsControlPlane, operationsEnvironment, recentOperatorOperations } from './control-plane'
4
+ import { alertStore } from './operations-runtime'
5
+
6
+ export default new Action({
7
+ name: 'IncidentIndexAction',
8
+ description: 'Returns native application alerts, rules, health checks, and notification delivery state.',
9
+ method: 'GET',
10
+ apiResponse: true,
11
+ async handle(_request: RequestInstance) {
12
+ const controlPlane = operationsControlPlane()
13
+ const environment = operationsEnvironment(controlPlane)
14
+ const store = alertStore()
15
+ const alerts = store.listAlerts(controlPlane.project.id, { environmentId: environment?.id, limit: 200 })
16
+ return {
17
+ environment,
18
+ alerts: alerts.map(alert => ({ ...alert, ownerName: alert.ownerActorId ? controlPlane.store.getActor(alert.ownerActorId)?.displayName : undefined })),
19
+ rules: store.listRules(controlPlane.project.id, environment?.id),
20
+ healthChecks: store.listHealthChecks(controlPlane.project.id, environment?.id),
21
+ channels: store.listChannels(controlPlane.organization.id),
22
+ deliveries: store.listDeliveries({ limit: 100 }),
23
+ operations: recentOperatorOperations('dashboard.incidents.', 20),
24
+ summary: {
25
+ firing: alerts.filter(alert => alert.state === 'firing').length,
26
+ critical: alerts.filter(alert => alert.state === 'firing' && alert.severity === 'critical').length,
27
+ acknowledged: alerts.filter(alert => Boolean(alert.acknowledgedAt)).length,
28
+ rules: store.listRules(controlPlane.project.id, environment?.id).filter(rule => rule.enabled).length,
29
+ },
30
+ }
31
+ },
32
+ })
@@ -0,0 +1,34 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { dashboardOperator, operationsControlPlane, trackOperatorOperation } from './control-plane'
5
+ import { stringValue } from './recovery-input'
6
+ import { alertStore } from './operations-runtime'
7
+
8
+ export default new Action({
9
+ name: 'IncidentUpdateAction',
10
+ description: 'Acknowledges, assigns, or silences a native application alert.',
11
+ method: 'PATCH',
12
+ apiResponse: true,
13
+ async handle(request: RequestInstance) {
14
+ const body = request.all() as Record<string, unknown>
15
+ const action = stringValue(body.action)
16
+ if (!['acknowledge', 'assign_self', 'unassign', 'silence'].includes(action))
17
+ return response.json({ message: 'Unsupported incident action.' }, 422)
18
+ const store = alertStore()
19
+ const alert = store.getAlert(String(request.getParam('id') || ''))
20
+ if (!alert || alert.projectId !== operationsControlPlane().project.id)
21
+ return response.json({ message: 'Alert not found.' }, 404)
22
+ const actor = await dashboardOperator(request)
23
+ const tracked = await trackOperatorOperation(request, `dashboard.incidents.${action}`, { alertId: alert.id }, async () => {
24
+ if (action === 'acknowledge') return store.acknowledge(alert.id, actor.id)
25
+ if (action === 'assign_self') return store.assign(alert.id, actor.id, actor.id)
26
+ if (action === 'unassign') return store.assign(alert.id, undefined, actor.id)
27
+ const until = stringValue(body.until)
28
+ if (!until || Number.isNaN(new Date(until).getTime()) || new Date(until) <= new Date())
29
+ throw new Error('A future silence expiry is required.')
30
+ return store.silenceAlert(alert.id, new Date(until).toISOString(), actor.id)
31
+ })
32
+ return { success: true, alert: tracked.result, operation: tracked.operation }
33
+ },
34
+ })
@@ -0,0 +1,30 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { dashboardOperationalError } from '../dashboard-response'
5
+ import { trackOperatorOperation } from './control-plane'
6
+ import { applyMigrationPlan, MigrationOperationError } from './migration-operations'
7
+ import { stringValue } from './recovery-input'
8
+
9
+ export default new Action({
10
+ name: 'MigrationApplyAction',
11
+ description: 'Applies an unchanged, explicitly confirmed model-derived migration plan.',
12
+ method: 'POST',
13
+ apiResponse: true,
14
+ async handle(request: RequestInstance) {
15
+ const input = request.all() as Record<string, unknown>
16
+ const revision = stringValue(input.revision)
17
+ const confirmation = stringValue(input.confirmation)
18
+ if (!revision || !confirmation)
19
+ return response.json({ message: 'Plan revision and typed confirmation are required.' }, 422)
20
+ try {
21
+ const tracked = await trackOperatorOperation(request, 'dashboard.migrations.apply', { revision }, () => applyMigrationPlan({ revision, confirmation }))
22
+ return { success: true, ...tracked.result, operation: tracked.operation }
23
+ }
24
+ catch (error) {
25
+ if (error instanceof MigrationOperationError)
26
+ return response.json({ message: error.message }, 409)
27
+ return dashboardOperationalError(error, 'The migration plan could not be applied.', 'MigrationApplyAction')
28
+ }
29
+ },
30
+ })
@@ -0,0 +1,24 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { dashboardOperationalError } from '../dashboard-response'
4
+ import { recentOperatorOperations } from './control-plane'
5
+ import { migrationPlan, reconcileMigrationLedgerPlan } from './migration-operations'
6
+
7
+ export default new Action({
8
+ name: 'MigrationIndexAction',
9
+ description: 'Returns the model-derived schema plan and migration ledger health.',
10
+ method: 'GET',
11
+ apiResponse: true,
12
+ async handle(_request: RequestInstance) {
13
+ try {
14
+ return {
15
+ ...await migrationPlan(),
16
+ reconciliation: await reconcileMigrationLedgerPlan(false),
17
+ operatorOperations: recentOperatorOperations('dashboard.migrations.', 20),
18
+ }
19
+ }
20
+ catch (error) {
21
+ return dashboardOperationalError(error, 'Migration operations could not be loaded.', 'MigrationIndexAction')
22
+ }
23
+ },
24
+ })
@@ -0,0 +1,23 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { trackOperatorOperation } from './control-plane'
5
+ import { migrationPlan, reconcileMigrationLedgerPlan } from './migration-operations'
6
+ import { stringValue } from './recovery-input'
7
+
8
+ export default new Action({
9
+ name: 'MigrationReconcileAction',
10
+ description: 'Conservatively reconciles provable migration ledger drift.',
11
+ method: 'POST',
12
+ apiResponse: true,
13
+ async handle(request: RequestInstance) {
14
+ const input = request.all() as Record<string, unknown>
15
+ const plan = await migrationPlan()
16
+ if (stringValue(input.revision) !== plan.revision)
17
+ return response.json({ message: 'The migration state changed. Refresh before reconciling.' }, 409)
18
+ if (stringValue(input.confirmation) !== `reconcile ${plan.environment}`)
19
+ return response.json({ message: `Type reconcile ${plan.environment} to confirm ledger repair.` }, 422)
20
+ const tracked = await trackOperatorOperation(request, 'dashboard.migrations.reconcile', { revision: plan.revision }, () => reconcileMigrationLedgerPlan(true))
21
+ return { success: true, result: tracked.result, operation: tracked.operation }
22
+ },
23
+ })
@@ -0,0 +1,91 @@
1
+ import type { BackupDestinationProvider } from '@stacksjs/ts-cloud'
2
+ import type { RequestInstance } from '@stacksjs/types'
3
+ import { Action } from '@stacksjs/actions'
4
+ import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
6
+ import { appendOperatorEvent } from './control-plane'
7
+ import { positiveNumber, stringValue } from './recovery-input'
8
+ import { recoveryEnvironment, recoveryRuntime, safeRecoveryDestination } from './recovery-runtime'
9
+
10
+ export default new Action({
11
+ name: 'RecoveryDestinationStoreAction',
12
+ description: 'Creates a native recovery destination with encrypted optional credentials.',
13
+ method: 'POST',
14
+ apiResponse: true,
15
+ async handle(request: RequestInstance) {
16
+ const input = request.all() as Record<string, unknown>
17
+ const name = stringValue(input.name)
18
+ const provider = stringValue(input.provider) as BackupDestinationProvider
19
+ const bucket = stringValue(input.bucket)
20
+ const encryption = stringValue(input.encryption) || 'provider'
21
+ if (!name)
22
+ return response.json({ message: 'A destination name is required.' }, 422)
23
+ if (!['aws_s3', 's3_compatible', 'aws_backup'].includes(provider))
24
+ return response.json({ message: 'Choose a supported destination provider.' }, 422)
25
+ if (provider !== 'aws_backup' && !bucket)
26
+ return response.json({ message: 'An object storage bucket is required.' }, 422)
27
+ if (!['provider', 'client_side', 'both'].includes(encryption))
28
+ return response.json({ message: 'Choose a supported encryption mode.' }, 422)
29
+
30
+ const runtime = recoveryRuntime()
31
+ const { controlPlane } = recoveryEnvironment()
32
+ const secretRoot = `secret://data-services/backups/${controlPlane.project.id}/${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
33
+ const credentials = input.credentials && typeof input.credentials === 'object'
34
+ ? input.credentials as Record<string, unknown>
35
+ : undefined
36
+ const accessKeyId = stringValue(credentials?.accessKeyId)
37
+ const secretAccessKey = stringValue(credentials?.secretAccessKey)
38
+ const sessionToken = stringValue(credentials?.sessionToken)
39
+ const encryptionKey = stringValue(input.encryptionKey)
40
+ const storedSecrets: string[] = []
41
+ if ((accessKeyId || secretAccessKey) && (!accessKeyId || !secretAccessKey))
42
+ return response.json({ message: 'Both access key id and secret access key are required.' }, 422)
43
+ if (encryption !== 'provider' && encryptionKey.length < 32)
44
+ return response.json({ message: 'Client encryption requires a key of at least 32 characters.' }, 422)
45
+
46
+ try {
47
+ let credentialRef: string | undefined
48
+ let encryptionKeyRef: string | undefined
49
+ if (accessKeyId || secretAccessKey) {
50
+ credentialRef = `${secretRoot}/credentials`
51
+ await runtime.secrets.put(credentialRef, JSON.stringify({ accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }))
52
+ storedSecrets.push(credentialRef)
53
+ }
54
+ if (encryption !== 'provider') {
55
+ encryptionKeyRef = `${secretRoot}/encryption-key`
56
+ await runtime.secrets.put(encryptionKeyRef, encryptionKey)
57
+ storedSecrets.push(encryptionKeyRef)
58
+ }
59
+
60
+ const destination = runtime.store.createDestination({
61
+ organizationId: controlPlane.organization.id,
62
+ projectId: controlPlane.project.id,
63
+ name,
64
+ provider,
65
+ endpoint: stringValue(input.endpoint) || undefined,
66
+ endpointPolicy: input.allowPrivate === true ? 'allow_private' : 'public_https',
67
+ bucket: bucket || undefined,
68
+ prefix: stringValue(input.prefix),
69
+ region: stringValue(input.region) || undefined,
70
+ forcePathStyle: input.forcePathStyle === true,
71
+ credentialRef,
72
+ encryption: encryption as 'provider' | 'client_side' | 'both',
73
+ encryptionKeyRef,
74
+ immutability: {
75
+ objectLock: Number(input.lockDays) > 0,
76
+ defaultRetentionDays: Number(input.lockDays) > 0 ? positiveNumber(input.lockDays, 1, 36_500) : undefined,
77
+ },
78
+ status: 'untested',
79
+ })
80
+ await appendOperatorEvent(request, 'backup.destination_created', {
81
+ destinationId: destination.id,
82
+ provider: destination.provider,
83
+ })
84
+ return response.json({ success: true, destination: safeRecoveryDestination(destination) }, 201)
85
+ }
86
+ catch (error) {
87
+ await Promise.all(storedSecrets.map(reference => runtime.secrets.remove(reference).catch(() => {})))
88
+ return dashboardOperationalError(error, 'The recovery destination could not be created.', 'RecoveryDestinationStoreAction', 422)
89
+ }
90
+ },
91
+ })
@@ -0,0 +1,34 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { S3BackupDestinationAdapter } from '@stacksjs/ts-cloud'
5
+ import { appendOperatorEvent } from './control-plane'
6
+ import { recoveryRuntime, safeRecoveryDestination } from './recovery-runtime'
7
+
8
+ export default new Action({
9
+ name: 'RecoveryDestinationTestAction',
10
+ description: 'Tests recovery destination write, read, checksum, and cleanup behavior.',
11
+ method: 'POST',
12
+ apiResponse: true,
13
+ async handle(request: RequestInstance) {
14
+ const runtime = recoveryRuntime()
15
+ const destination = runtime.store.getDestination(String(request.getParam('id') || ''))
16
+ if (!destination)
17
+ return response.json({ message: 'Recovery destination not found.' }, 404)
18
+ if (destination.provider === 'aws_backup')
19
+ return response.json({ message: 'AWS Backup destinations are verified by creating a provider recovery point.' }, 409)
20
+
21
+ try {
22
+ await new S3BackupDestinationAdapter(runtime.secrets).test(destination)
23
+ const tested = runtime.store.recordDestinationTest(destination.id, { ok: true })
24
+ await appendOperatorEvent(request, 'backup.destination_tested', { destinationId: destination.id, ok: true })
25
+ return { success: true, destination: safeRecoveryDestination(tested), message: `${destination.name} passed write, read, checksum, and cleanup checks.` }
26
+ }
27
+ catch (error) {
28
+ const message = error instanceof Error ? error.message : String(error)
29
+ runtime.store.recordDestinationTest(destination.id, { ok: false, error: message })
30
+ await appendOperatorEvent(request, 'backup.destination_tested', { destinationId: destination.id, ok: false })
31
+ return response.json({ message }, 502)
32
+ }
33
+ },
34
+ })
@@ -0,0 +1,58 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { dashboardOperationalError } from '../dashboard-response'
4
+ import { recoveryEnvironment, recoveryRuntime, safeRecoveryDestination } from './recovery-runtime'
5
+
6
+ export default new Action({
7
+ name: 'RecoveryIndexAction',
8
+ description: 'Returns recovery coverage, destinations, policies, points, jobs, and eligible resources.',
9
+ method: 'GET',
10
+ apiResponse: true,
11
+ async handle(_request: RequestInstance) {
12
+ try {
13
+ const { controlPlane, environment } = recoveryEnvironment()
14
+ const runtime = recoveryRuntime()
15
+ const policies = runtime.store.listPolicies(controlPlane.project.id, environment.id)
16
+ const policyIds = new Set(policies.map(policy => policy.id))
17
+ const recoveryPoints = runtime.store
18
+ .listRecoveryPoints(controlPlane.project.id)
19
+ .filter(point => !point.policyId || policyIds.has(point.policyId))
20
+ const jobs = runtime.store
21
+ .listJobs(controlPlane.project.id)
22
+ .filter(job => !job.policyId || policyIds.has(job.policyId))
23
+ const coverage = runtime.store
24
+ .coverage(controlPlane.project.id)
25
+ .filter(item => policyIds.has(item.policy.id))
26
+ const resources = controlPlane.store.listResources(controlPlane.project.id, environment.id)
27
+ const dataServices = runtime.dataServices.list(controlPlane.project.id, environment.id).map(service => ({
28
+ id: service.id,
29
+ name: service.name,
30
+ engine: service.engine,
31
+ provider: service.provider,
32
+ status: service.status,
33
+ }))
34
+
35
+ return {
36
+ environment: { id: environment.id, name: environment.name, slug: environment.slug },
37
+ coverage,
38
+ destinations: runtime.store.listDestinations(controlPlane.project.id).map(safeRecoveryDestination),
39
+ policies,
40
+ recoveryPoints,
41
+ jobs,
42
+ resources: resources.map(resource => ({ id: resource.id, kind: resource.kind, name: resource.name, slug: resource.slug })),
43
+ dataServices,
44
+ summary: {
45
+ policies: policies.length,
46
+ protected: coverage.filter(item => !item.missedRpo && item.destinationHealthy).length,
47
+ missedRpo: coverage.filter(item => item.missedRpo).length,
48
+ unverified: recoveryPoints.filter(point => point.verificationState !== 'verified' && point.status === 'available').length,
49
+ queuedJobs: jobs.filter(job => job.status === 'queued' || job.status === 'running').length,
50
+ latestPoint: recoveryPoints.map(point => point.pointInTime).sort().at(-1) || null,
51
+ },
52
+ }
53
+ }
54
+ catch (error) {
55
+ return dashboardOperationalError(error, 'Recovery operations could not be loaded.', 'RecoveryIndexAction')
56
+ }
57
+ },
58
+ })
@@ -0,0 +1,90 @@
1
+ import type { BackupResourceKind } from '@stacksjs/ts-cloud'
2
+ import type { RequestInstance } from '@stacksjs/types'
3
+ import { Action } from '@stacksjs/actions'
4
+ import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
6
+ import { appendOperatorEvent } from './control-plane'
7
+ import { positiveNumber, RECOVERY_KINDS, stringList, stringValue } from './recovery-input'
8
+ import { recoveryEnvironment, recoveryRuntime } from './recovery-runtime'
9
+
10
+ export default new Action({
11
+ name: 'RecoveryPolicyStoreAction',
12
+ description: 'Creates a scheduled native recovery policy for a configured source and destination.',
13
+ method: 'POST',
14
+ apiResponse: true,
15
+ async handle(request: RequestInstance) {
16
+ const input = request.all() as Record<string, unknown>
17
+ const name = stringValue(input.name)
18
+ const destinationId = stringValue(input.destinationId)
19
+ const resourceKind = stringValue(input.resourceKind) as BackupResourceKind
20
+ const includePatterns = stringList(input.includePatterns)
21
+ const excludePatterns = stringList(input.excludePatterns)
22
+ if (!name)
23
+ return response.json({ message: 'A policy name is required.' }, 422)
24
+ if (!RECOVERY_KINDS.includes(resourceKind))
25
+ return response.json({ message: 'Choose a supported recovery source.' }, 422)
26
+
27
+ const runtime = recoveryRuntime()
28
+ const { controlPlane, environment } = recoveryEnvironment()
29
+ const destination = runtime.store.getDestination(destinationId)
30
+ if (!destination || destination.projectId !== controlPlane.project.id)
31
+ return response.json({ message: 'Choose a valid recovery destination.' }, 422)
32
+ if (destination.provider === 'aws_backup' && resourceKind !== 'infrastructure')
33
+ return response.json({ message: 'AWS Backup destinations require an infrastructure recovery policy.' }, 422)
34
+
35
+ const resourceId = stringValue(input.resourceId) || undefined
36
+ const dataServiceId = stringValue(input.dataServiceId) || undefined
37
+ if (['managed_database', 'logical_database'].includes(resourceKind)) {
38
+ const service = dataServiceId ? runtime.dataServices.get(dataServiceId) : undefined
39
+ if (!service || service.projectId !== controlPlane.project.id || service.environmentId !== environment.id)
40
+ return response.json({ message: 'Database recovery policies require a data service in this environment.' }, 422)
41
+ }
42
+ if (['volume', 'files'].includes(resourceKind)) {
43
+ const resource = resourceId ? controlPlane.store.getResource(resourceId) : undefined
44
+ if (!resource || resource.projectId !== controlPlane.project.id || resource.environmentId !== environment.id)
45
+ return response.json({ message: 'This recovery source requires a resource in the current environment.' }, 422)
46
+ }
47
+ if (resourceKind === 'volume' && includePatterns.length === 0)
48
+ return response.json({ message: 'Volume policies require the Docker volume name.' }, 422)
49
+ if (resourceKind === 'files' && includePatterns.length === 0)
50
+ return response.json({ message: 'File policies require at least one project-relative include path.' }, 422)
51
+ if (resourceKind === 'infrastructure' && (!includePatterns.some(value => value.startsWith('resource:arn:')) || !includePatterns.some(value => value.startsWith('role:arn:'))))
52
+ return response.json({ message: 'Infrastructure policies require resource:arn: and role:arn: include entries.' }, 422)
53
+
54
+ try {
55
+ const policy = runtime.store.createPolicy({
56
+ organizationId: controlPlane.organization.id,
57
+ projectId: controlPlane.project.id,
58
+ environmentId: environment.id,
59
+ resourceId,
60
+ dataServiceId,
61
+ destinationId: destination.id,
62
+ name,
63
+ resourceKind,
64
+ schedule: stringValue(input.schedule) || 'daily',
65
+ timezone: stringValue(input.timezone) || 'UTC',
66
+ retention: {
67
+ keepLast: positiveNumber(input.keepLast, 7, 10_000),
68
+ expireAfterDays: positiveNumber(input.expireAfterDays, 30, 36_500),
69
+ },
70
+ compression: ['none', 'gzip', 'zstd'].includes(stringValue(input.compression))
71
+ ? stringValue(input.compression) as 'none' | 'gzip' | 'zstd'
72
+ : 'gzip',
73
+ encryption: 'destination',
74
+ includePatterns,
75
+ excludePatterns,
76
+ expectedRpoMinutes: positiveNumber(input.expectedRpoMinutes, 1_440, 525_600),
77
+ expectedRtoMinutes: positiveNumber(input.expectedRtoMinutes, 120, 525_600),
78
+ enabled: input.enabled !== false,
79
+ })
80
+ await appendOperatorEvent(request, 'backup.policy_created', {
81
+ policyId: policy.id,
82
+ resourceKind: policy.resourceKind,
83
+ }, policy.resourceId)
84
+ return response.json({ success: true, policy }, 201)
85
+ }
86
+ catch (error) {
87
+ return dashboardOperationalError(error, 'The recovery policy could not be created.', 'RecoveryPolicyStoreAction', 422)
88
+ }
89
+ },
90
+ })
@@ -0,0 +1,32 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { appendOperatorEvent } from './control-plane'
5
+ import { recoveryEnvironment, recoveryRuntime } from './recovery-runtime'
6
+
7
+ export default new Action({
8
+ name: 'RecoveryProtectAction',
9
+ description: 'Pins or holds one recovery point against retention cleanup.',
10
+ method: 'PATCH',
11
+ apiResponse: true,
12
+ async handle(request: RequestInstance) {
13
+ const runtime = recoveryRuntime()
14
+ const { controlPlane } = recoveryEnvironment()
15
+ const point = runtime.store.getRecoveryPoint(String(request.getParam('id') || ''))
16
+ if (!point || point.projectId !== controlPlane.project.id)
17
+ return response.json({ message: 'Recovery point not found.' }, 404)
18
+ const input = request.all() as Record<string, unknown>
19
+ if (typeof input.pinned !== 'boolean' && typeof input.held !== 'boolean')
20
+ return response.json({ message: 'Set pinned or held to a boolean value.' }, 422)
21
+ const updated = runtime.store.updateRecoveryPoint(point.id, {
22
+ pinned: typeof input.pinned === 'boolean' ? input.pinned : point.pinned,
23
+ held: typeof input.held === 'boolean' ? input.held : point.held,
24
+ })
25
+ await appendOperatorEvent(request, 'backup.protection_updated', {
26
+ recoveryPointId: point.id,
27
+ pinned: updated.pinned,
28
+ held: updated.held,
29
+ }, point.resourceId)
30
+ return { success: true, recoveryPoint: updated }
31
+ },
32
+ })