@stacksjs/defaults 0.74.33 → 0.74.35

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 (31) hide show
  1. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +90 -0
  3. package/ai/skills/stacks-orm/SKILL.md +1 -1
  4. package/ai/skills/stacks-storage/SKILL.md +58 -4
  5. package/app/Actions/Dashboard/Content/FileFavoriteAction.ts +25 -0
  6. package/app/Actions/Dashboard/Content/FileReprocessAction.ts +31 -0
  7. package/app/Actions/Dashboard/Content/FileTagsAction.ts +27 -0
  8. package/app/Actions/Dashboard/Content/file-manager.test.ts +47 -27
  9. package/app/Actions/Dashboard/Content/file-manager.ts +338 -12
  10. package/app/Actions/Dashboard/Content/file-metadata-store.ts +432 -0
  11. package/app/Actions/Dashboard/Content/file-metadata.test.ts +357 -0
  12. package/app/Actions/Dashboard/Content/file-metadata.ts +550 -0
  13. package/app/Actions/Dashboard/Content/file-pipeline.test.ts +344 -0
  14. package/app/Actions/Dashboard/Remote/RemoteCommandIndexAction.ts +29 -0
  15. package/app/Actions/Dashboard/Remote/RemoteCommandRunAction.ts +57 -0
  16. package/app/Actions/Dashboard/Remote/remote-commands.test.ts +272 -0
  17. package/app/Actions/Dashboard/Remote/remote-commands.ts +297 -0
  18. package/app/Actions/Dashboard/Remote/remote-routes.test.ts +58 -0
  19. package/app/Actions/Dashboard/Remote/ssh-runner.ts +94 -0
  20. package/app/Jobs/OptimizeStorageImageJob.ts +74 -0
  21. package/app/Jobs/TagStorageMediaJob.ts +122 -0
  22. package/app/Jobs/TranscodeStorageVideoJob.ts +88 -0
  23. package/app/Models/StorageItem.ts +123 -0
  24. package/app/Models/StorageItemTask.ts +134 -0
  25. package/ide/vscode/package.json +1 -1
  26. package/package.json +2 -2
  27. package/routes/dashboard-api.ts +25 -0
  28. package/vcs/github/workflows/buddy-bot.yml +109 -0
  29. package/vcs/github/workflows/ci.yml +3 -3
  30. package/vcs/github/workflows/release.yml +1 -1
  31. package/vcs/github/renovate.json +0 -5
@@ -0,0 +1,272 @@
1
+ // Running a known operation on a configured host (stacksjs/stacks#960).
2
+ //
3
+ // What is tested here is the security model, not SSH. The transport is
4
+ // injected, so every rule below is asserted without a network: which hosts can
5
+ // be reached, who may reach them, what is recorded, and what happens when any
6
+ // of that is not configured.
7
+ //
8
+ // The properties worth stating up front, because each is a way this surface
9
+ // becomes remote code execution if it is wrong:
10
+ //
11
+ // - a request cannot name a host, only a key in the registry
12
+ // - a request cannot name a command, only a key in the registry
13
+ // - a host without a pinned key is refused rather than trusted
14
+ // - authorization fails CLOSED when no gate is configured
15
+ // - a run is recorded before it starts, not only when it finishes
16
+
17
+ import type { UserModel } from '@stacksjs/orm'
18
+ import type { RemoteAuditSink, RemoteCommand, RemoteHost } from './remote-commands'
19
+ import { beforeEach, describe, expect, it } from 'bun:test'
20
+ import {
21
+ authorize,
22
+ clampOutput,
23
+ normalizeTimeout,
24
+ RemoteCommandError,
25
+ resolveCommand,
26
+ resolveHost,
27
+ runRemoteCommand,
28
+ sshArgv,
29
+ } from './remote-commands'
30
+
31
+ const host: RemoteHost = {
32
+ key: 'app',
33
+ host: 'app.example.com',
34
+ user: 'deploy',
35
+ knownHosts: 'app.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample',
36
+ }
37
+
38
+ const hosts: RemoteHost[] = [
39
+ host,
40
+ { key: 'db', host: 'db.example.com', user: 'deploy', port: 2222, identityFile: '/keys/db', knownHosts: 'db.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIdb' },
41
+ { key: 'unpinned', host: 'new.example.com', user: 'deploy', knownHosts: '' },
42
+ ]
43
+
44
+ const commands: RemoteCommand[] = [
45
+ { key: 'disk', description: 'Free space', argv: ['df', '-h'] },
46
+ { key: 'restart-api', description: 'Restart the API', argv: ['systemctl', 'restart', 'api'], hosts: ['app'] },
47
+ ]
48
+
49
+ const user = { id: 1, email: 'ops@example.com' } as unknown as UserModel
50
+
51
+ let recorded: string[] = []
52
+ const audit: RemoteAuditSink = {
53
+ async started(entry) {
54
+ recorded.push(`started:${entry.user}:${entry.hostKey}:${entry.commandKey}`)
55
+ },
56
+ async finished(entry) {
57
+ recorded.push(`finished:${entry.hostKey}:${entry.commandKey}:${entry.exitCode}:${entry.timedOut}`)
58
+ },
59
+ }
60
+
61
+ beforeEach(() => {
62
+ recorded = []
63
+ })
64
+
65
+ const ok = async (): Promise<{ exitCode: number, stdout: string, stderr: string, timedOut: boolean }> =>
66
+ ({ exitCode: 0, stdout: 'out', stderr: '', timedOut: false })
67
+
68
+ describe('resolveHost', () => {
69
+ it('resolves a declared host by key', () => {
70
+ expect(resolveHost(hosts, 'app')).toBe(host)
71
+ })
72
+
73
+ it('404s a key nobody declared', () => {
74
+ // The property that matters: a request names a KEY, and a key that is not
75
+ // in config reaches nothing. There is no path from the request to a
76
+ // hostname.
77
+ expect(() => resolveHost(hosts, 'evil.example.com')).toThrow(/No configured host/)
78
+ try {
79
+ resolveHost(hosts, 'nope')
80
+ }
81
+ catch (error) {
82
+ expect((error as RemoteCommandError).status).toBe(404)
83
+ }
84
+ })
85
+
86
+ it('refuses a host with no pinned key rather than trusting one on first contact', () => {
87
+ // Accepting a key on first contact is what makes a session into a
88
+ // long-lived box MITM-open. `sshExec` in ts-cloud does exactly that, on
89
+ // purpose, for boxes a minute old - which is why this does not reuse it.
90
+ expect(() => resolveHost(hosts, 'unpinned')).toThrow(/no pinned host key/)
91
+ })
92
+
93
+ it('rejects a non-string key', () => {
94
+ for (const key of [undefined, null, 42, {}, ''])
95
+ expect(() => resolveHost(hosts, key)).toThrow(RemoteCommandError)
96
+ })
97
+ })
98
+
99
+ describe('resolveCommand', () => {
100
+ it('resolves a declared command', () => {
101
+ expect(resolveCommand(commands, 'disk', host).argv).toEqual(['df', '-h'])
102
+ })
103
+
104
+ it('404s a command nobody declared', () => {
105
+ // No request carries a command, so there is nothing to escape and no shell
106
+ // to reach.
107
+ expect(() => resolveCommand(commands, 'rm -rf /', host)).toThrow(/No configured command/)
108
+ })
109
+
110
+ it('403s a command that is scoped away from this host', () => {
111
+ // 403 rather than 404: the command exists, this host is not one of its
112
+ // targets. Scoping `restart-api` to the app box is a deliberate
113
+ // restriction, and reporting it as "not found" would read as a typo.
114
+ const db = resolveHost(hosts, 'db')
115
+ expect(() => resolveCommand(commands, 'restart-api', db)).toThrow(/not permitted on host/)
116
+ try {
117
+ resolveCommand(commands, 'restart-api', db)
118
+ }
119
+ catch (error) {
120
+ expect((error as RemoteCommandError).status).toBe(403)
121
+ }
122
+ })
123
+
124
+ it('lets an unscoped command run anywhere', () => {
125
+ expect(resolveCommand(commands, 'disk', resolveHost(hosts, 'db')).key).toBe('disk')
126
+ })
127
+ })
128
+
129
+ describe('sshArgv', () => {
130
+ it('verifies the host key against the pinned file', () => {
131
+ const argv = sshArgv(host, commands[0]!, '/tmp/known_hosts')
132
+ expect(argv).toContain('StrictHostKeyChecking=yes')
133
+ expect(argv).toContain('UserKnownHostsFile=/tmp/known_hosts')
134
+ })
135
+
136
+ it('fails rather than prompting for a password', () => {
137
+ // A prompt on a server has nobody to answer it, so the request would hang
138
+ // until it timed out with no useful message.
139
+ expect(sshArgv(host, commands[0]!, '/tmp/kh')).toContain('BatchMode=yes')
140
+ })
141
+
142
+ it('passes the remote argv after `--`, as separate arguments', () => {
143
+ // Never joined into a string. A joined command is a shell command, and a
144
+ // shell command is where an injection would live if one could reach it.
145
+ const argv = sshArgv(host, commands[1]!, '/tmp/kh')
146
+ expect(argv.slice(argv.indexOf('--'))).toEqual(['--', 'systemctl', 'restart', 'api'])
147
+ })
148
+
149
+ it('carries the port and identity when the host declares them', () => {
150
+ const argv = sshArgv(hosts[1]!, commands[0]!, '/tmp/kh')
151
+ expect(argv).toContain('-p')
152
+ expect(argv[argv.indexOf('-p') + 1]).toBe('2222')
153
+ expect(argv[argv.indexOf('-i') + 1]).toBe('/keys/db')
154
+ // Without this ssh may offer an agent key instead of the configured one.
155
+ expect(argv).toContain('IdentitiesOnly=yes')
156
+ })
157
+
158
+ it('addresses the host as user@host', () => {
159
+ expect(sshArgv(host, commands[0]!, '/tmp/kh')).toContain('deploy@app.example.com')
160
+ })
161
+ })
162
+
163
+ describe('authorize', () => {
164
+ it('401s without a user', async () => {
165
+ expect(authorize(() => true, null, host, commands[0]!)).rejects.toThrow(/Authentication is required/)
166
+ })
167
+
168
+ it('fails CLOSED when no authorizer is configured', async () => {
169
+ // The opposite of the websocket authenticator next door, which proceeds
170
+ // when none is installed. For a surface that runs commands on a server, an
171
+ // app that has not defined the gate must get a refusal rather than a shell.
172
+ expect(authorize(undefined, user, host, commands[0]!)).rejects.toThrow(/not authorized on this application/)
173
+ })
174
+
175
+ it('403s when the gate says no', async () => {
176
+ expect(authorize(() => false, user, host, commands[0]!)).rejects.toThrow(/not permitted to run/)
177
+ })
178
+
179
+ it('passes both keys to the gate, so an app can scope by either', async () => {
180
+ const seen: string[] = []
181
+ await authorize((_user, hostKey, commandKey) => {
182
+ seen.push(hostKey, commandKey)
183
+ return true
184
+ }, user, host, commands[1]!)
185
+
186
+ expect(seen).toEqual(['app', 'restart-api'])
187
+ })
188
+ })
189
+
190
+ describe('runRemoteCommand', () => {
191
+ const context = {
192
+ user,
193
+ hosts,
194
+ commands,
195
+ authorizer: () => true,
196
+ run: ok,
197
+ audit,
198
+ }
199
+
200
+ it('records the run before it starts and again when it ends', async () => {
201
+ // A run recorded only on completion loses the two worth having: the command
202
+ // that hung, and the one whose process died with the box.
203
+ await runRemoteCommand({ hostKey: 'app', commandKey: 'disk' }, context)
204
+
205
+ expect(recorded).toEqual([
206
+ 'started:ops@example.com:app:disk',
207
+ 'finished:app:disk:0:false',
208
+ ])
209
+ })
210
+
211
+ it('records nothing for a run that was refused', async () => {
212
+ // Authorization happens before the audit entry, so a refusal is not an
213
+ // audit record saying somebody ran something they did not.
214
+ expect(runRemoteCommand({ hostKey: 'app', commandKey: 'disk' }, { ...context, authorizer: () => false }))
215
+ .rejects.toThrow(/not permitted/)
216
+
217
+ await Bun.sleep(0)
218
+ expect(recorded).toEqual([])
219
+ })
220
+
221
+ it('records nothing for a host that does not exist', async () => {
222
+ expect(runRemoteCommand({ hostKey: 'ghost', commandKey: 'disk' }, context)).rejects.toThrow(/No configured host/)
223
+ await Bun.sleep(0)
224
+ expect(recorded).toEqual([])
225
+ })
226
+
227
+ it('reports the outcome, including a timeout', async () => {
228
+ const result = await runRemoteCommand({ hostKey: 'app', commandKey: 'disk' }, {
229
+ ...context,
230
+ run: async () => ({ exitCode: 143, stdout: 'partial', stderr: 'killed', timedOut: true }),
231
+ })
232
+
233
+ expect(result.timedOut).toBeTrue()
234
+ expect(result.exitCode).toBe(143)
235
+ expect(recorded.at(-1)).toBe('finished:app:disk:143:true')
236
+ })
237
+
238
+ it('reports a non-zero exit as a result rather than an error', async () => {
239
+ // `systemctl restart` failing is information the operator wants, not an
240
+ // exception - the run happened and the audit record says what it did.
241
+ const result = await runRemoteCommand({ hostKey: 'app', commandKey: 'disk' }, {
242
+ ...context,
243
+ run: async () => ({ exitCode: 1, stdout: '', stderr: 'no such unit', timedOut: false }),
244
+ })
245
+
246
+ expect(result.exitCode).toBe(1)
247
+ expect(result.stderr).toBe('no such unit')
248
+ })
249
+ })
250
+
251
+ describe('output and timeout bounds', () => {
252
+ it('truncates with a marker rather than cutting silently', () => {
253
+ const clamped = clampOutput('x'.repeat(100), 10)
254
+ expect(clamped).toStartWith('xxxxxxxxxx')
255
+ expect(clamped).toContain('truncated at 10 characters')
256
+ })
257
+
258
+ it('leaves output within the limit alone', () => {
259
+ expect(clampOutput('short', 10)).toBe('short')
260
+ })
261
+
262
+ it('caps a timeout rather than honouring an unbounded one', () => {
263
+ expect(normalizeTimeout(undefined)).toBe(30_000)
264
+ expect(normalizeTimeout(5_000)).toBe(5_000)
265
+ expect(normalizeTimeout(10_000_000)).toBe(300_000)
266
+ })
267
+
268
+ it('rejects a timeout that is not a positive number', () => {
269
+ for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY])
270
+ expect(() => normalizeTimeout(value)).toThrow(RemoteCommandError)
271
+ })
272
+ })
@@ -0,0 +1,297 @@
1
+ import type { UserModel } from '@stacksjs/orm'
2
+ import type { ResponseStatus } from '@stacksjs/bun-router'
3
+
4
+ /**
5
+ * Running a known operation on a configured host, from the dashboard
6
+ * (stacksjs/stacks#960).
7
+ *
8
+ * ## Why this is not a terminal
9
+ *
10
+ * The request asked for SSH in the dashboard, and named Termius. A browser
11
+ * terminal into a production box is remote code execution as a feature - the
12
+ * highest-privilege surface a dashboard can have - so what ships first is the
13
+ * part that carries most of the value and cannot become an arbitrary shell:
14
+ * run *this named operation* on *that host* and show me the output. Restart a
15
+ * service, tail a log, check disk.
16
+ *
17
+ * The command is chosen from a registry the config declares. The request names
18
+ * a KEY; it never carries a command, so there is no argument to escape and no
19
+ * shell to reach. An interactive session is a strictly larger problem and is
20
+ * tracked separately - `Bun.spawn` has no PTY, and `ssh -tt` gives a remote one
21
+ * but cannot propagate a window resize without a local TTY.
22
+ *
23
+ * ## What makes it safe to expose at all
24
+ *
25
+ * Four things, none of which the existing infrastructure gave for free:
26
+ *
27
+ * 1. **Host keys are verified.** `sshExec` in `@stacksjs/ts-cloud` disables host
28
+ * key checking on purpose - it targets boxes created a minute ago whose keys
29
+ * cannot be known. For a long-lived production host that is MITM-open, so
30
+ * this does not reuse it. `StrictHostKeyChecking=yes` against a configured
31
+ * known-hosts file, and a host with no pinned key is refused rather than
32
+ * trusted on first contact.
33
+ * 2. **Hosts come from config, never the request.** The request names a key in
34
+ * the registry. An unknown key is a 404, so no request can reach a host the
35
+ * operator did not declare.
36
+ * 3. **Authorization is per host, and has no local bypass.** The dashboard's
37
+ * `guard()` helper drops auth entirely when `APP_ENV` is local or test,
38
+ * which for this surface would be an unauthenticated shell on every
39
+ * developer machine reachable on the network. This checks a gate instead.
40
+ * 4. **Every run is recorded** - who, which host, which command, when, exit
41
+ * code - before it starts and again when it ends.
42
+ */
43
+
44
+ /** A host the dashboard may reach, as `config/cloud.ts` declares it. */
45
+ export interface RemoteHost {
46
+ /** Stable key the request names. Never a hostname. */
47
+ key: string
48
+ /** Hostname or IP. */
49
+ host: string
50
+ /** SSH user. */
51
+ user: string
52
+ /** SSH port. Omit for 22. */
53
+ port?: number
54
+ /** Private key passed as `ssh -i`. Omit to use the ambient agent. */
55
+ identityFile?: string
56
+ /**
57
+ * Pinned host keys for this host, in `known_hosts` format.
58
+ *
59
+ * Required. A host without one cannot be reached: accepting a key on first
60
+ * contact is what makes an interactive session into a production box
61
+ * MITM-open, and there is no reason to do it here - the operator declaring
62
+ * the host can declare its fingerprint at the same time.
63
+ */
64
+ knownHosts: string
65
+ }
66
+
67
+ /** An operation that may be run, as config declares it. */
68
+ export interface RemoteCommand {
69
+ /** Stable key the request names. */
70
+ key: string
71
+ /** What it does, shown in the dashboard. */
72
+ description: string
73
+ /**
74
+ * The argv to run on the host.
75
+ *
76
+ * An ARRAY, and never interpolated. A string would be a shell command, and a
77
+ * shell command with any caller-supplied part in it is an injection - which
78
+ * is the whole reason the request cannot carry one.
79
+ */
80
+ argv: readonly string[]
81
+ /** Host keys this command may run on. Omit for every host. */
82
+ hosts?: readonly string[]
83
+ }
84
+
85
+ export class RemoteCommandError extends Error {
86
+ readonly status: ResponseStatus
87
+
88
+ constructor(message: string, status: ResponseStatus = 422) {
89
+ super(message)
90
+ this.name = 'RemoteCommandError'
91
+ this.status = status
92
+ }
93
+ }
94
+
95
+ /** What a completed run produced. */
96
+ export interface RemoteCommandResult {
97
+ host: string
98
+ command: string
99
+ exitCode: number
100
+ stdout: string
101
+ stderr: string
102
+ /** Milliseconds from spawn to exit. */
103
+ durationMs: number
104
+ /** Whether the run was cut short by {@link RemoteRunOptions.timeoutMs}. */
105
+ timedOut: boolean
106
+ }
107
+
108
+ /** How a run reaches the host. Injected so the rules can be tested without a network. */
109
+ export type RemoteRunner = (
110
+ argv: readonly string[],
111
+ options: { timeoutMs: number },
112
+ ) => Promise<{ exitCode: number, stdout: string, stderr: string, timedOut: boolean }>
113
+
114
+ /** Where a run is recorded. Injected for the same reason. */
115
+ export interface RemoteAuditSink {
116
+ started: (entry: { user: string, hostKey: string, commandKey: string, at: string }) => Promise<void>
117
+ finished: (entry: { user: string, hostKey: string, commandKey: string, at: string, exitCode: number, durationMs: number, timedOut: boolean }) => Promise<void>
118
+ }
119
+
120
+ export interface RemoteRunOptions {
121
+ /** Give up after this long and report `timedOut`. */
122
+ timeoutMs?: number
123
+ /** Trim output beyond this many characters, so one run cannot fill a response. */
124
+ maxOutputChars?: number
125
+ }
126
+
127
+ /** A run that has not finished in this long is not going to be useful in a dashboard. */
128
+ const DEFAULT_TIMEOUT_MS = 30_000
129
+ const MAX_TIMEOUT_MS = 300_000
130
+ const DEFAULT_MAX_OUTPUT = 64 * 1024
131
+
132
+ /**
133
+ * The `ssh` argv for a command on a host.
134
+ *
135
+ * Every option here is load bearing:
136
+ *
137
+ * - `StrictHostKeyChecking=yes` and a per-host `UserKnownHostsFile` are what
138
+ * distinguish this from `sshExec`. Without them a changed host key is
139
+ * accepted silently, which is the entire attack.
140
+ * - `BatchMode=yes` means a host that wants a password fails instead of hanging
141
+ * on a prompt no one can answer.
142
+ * - `--` separates the ssh options from the remote argv, and the remote argv is
143
+ * passed as separate arguments rather than joined, so nothing in it is
144
+ * interpreted by a local shell.
145
+ */
146
+ export function sshArgv(host: RemoteHost, command: RemoteCommand, knownHostsPath: string): string[] {
147
+ const argv = [
148
+ 'ssh',
149
+ '-o', 'StrictHostKeyChecking=yes',
150
+ '-o', `UserKnownHostsFile=${knownHostsPath}`,
151
+ '-o', 'BatchMode=yes',
152
+ '-o', 'ConnectTimeout=10',
153
+ ]
154
+
155
+ if (host.port !== undefined)
156
+ argv.push('-p', String(host.port))
157
+ if (host.identityFile)
158
+ argv.push('-i', host.identityFile, '-o', 'IdentitiesOnly=yes')
159
+
160
+ argv.push(`${host.user}@${host.host}`, '--', ...command.argv)
161
+ return argv
162
+ }
163
+
164
+ /** The host with this key, or a 404. Hosts never come from the request itself. */
165
+ export function resolveHost(hosts: readonly RemoteHost[], key: unknown): RemoteHost {
166
+ if (typeof key !== 'string' || !key.trim())
167
+ throw new RemoteCommandError('A host key is required.', 422)
168
+
169
+ const host = hosts.find(entry => entry.key === key)
170
+ if (!host)
171
+ throw new RemoteCommandError(`No configured host named "${key}".`, 404)
172
+
173
+ // Refused rather than trusted on first contact. A host declared without its
174
+ // fingerprint is a configuration mistake, and the failure mode of guessing is
175
+ // silent.
176
+ if (!host.knownHosts.trim())
177
+ throw new RemoteCommandError(`Host "${key}" has no pinned host key; add one to its \`knownHosts\` before it can be reached.`, 422)
178
+
179
+ return host
180
+ }
181
+
182
+ /** The command with this key, if it may run on this host. */
183
+ export function resolveCommand(commands: readonly RemoteCommand[], key: unknown, host: RemoteHost): RemoteCommand {
184
+ if (typeof key !== 'string' || !key.trim())
185
+ throw new RemoteCommandError('A command key is required.', 422)
186
+
187
+ const command = commands.find(entry => entry.key === key)
188
+ if (!command)
189
+ throw new RemoteCommandError(`No configured command named "${key}".`, 404)
190
+
191
+ // A command scoped to some hosts is a deliberate restriction - `deploy` on
192
+ // the app box and not on the database box - so an unscoped run of it is a 403
193
+ // rather than a 404: the command exists, this host is not one of its targets.
194
+ if (command.hosts && !command.hosts.includes(host.key))
195
+ throw new RemoteCommandError(`Command "${command.key}" is not permitted on host "${host.key}".`, 403)
196
+
197
+ return command
198
+ }
199
+
200
+ /**
201
+ * Whether `user` may run `command` on `host`.
202
+ *
203
+ * A gate rather than a role, because the request was for "certain users" and
204
+ * `role:admin` is one bit. The gate receives both keys, so an application can
205
+ * scope by host, by command, or by both without this file knowing how.
206
+ */
207
+ export type RemoteAuthorizer = (user: UserModel | null, hostKey: string, commandKey: string) => Promise<boolean> | boolean
208
+
209
+ /** Refuses when no authorizer is configured. */
210
+ export async function authorize(
211
+ authorizer: RemoteAuthorizer | undefined,
212
+ user: UserModel | null,
213
+ host: RemoteHost,
214
+ command: RemoteCommand,
215
+ ): Promise<void> {
216
+ if (!user)
217
+ throw new RemoteCommandError('Authentication is required.', 401)
218
+
219
+ // Fails CLOSED. An app that has not defined the gate gets a refusal, not a
220
+ // shell - the opposite of the websocket authenticator next door, which
221
+ // proceeds when none is installed for backwards-compatibility.
222
+ if (!authorizer)
223
+ throw new RemoteCommandError('Remote commands are not authorized on this application.', 403)
224
+
225
+ if (!await authorizer(user, host.key, command.key))
226
+ throw new RemoteCommandError(`You are not permitted to run "${command.key}" on "${host.key}".`, 403)
227
+ }
228
+
229
+ /** Output beyond `limit`, with a marker rather than a silent cut. */
230
+ export function clampOutput(value: string, limit: number = DEFAULT_MAX_OUTPUT): string {
231
+ if (value.length <= limit)
232
+ return value
233
+ return `${value.slice(0, limit)}\n… output truncated at ${limit} characters`
234
+ }
235
+
236
+ /** A timeout inside the bounds a dashboard request can wait for. */
237
+ export function normalizeTimeout(value: number | undefined): number {
238
+ if (value === undefined)
239
+ return DEFAULT_TIMEOUT_MS
240
+ if (!Number.isFinite(value) || value <= 0)
241
+ throw new RemoteCommandError('The timeout must be a positive number of milliseconds.', 422)
242
+ return Math.min(value, MAX_TIMEOUT_MS)
243
+ }
244
+
245
+ /**
246
+ * Resolve, authorize, record, run, record.
247
+ *
248
+ * The audit entry is written BEFORE the command runs and again after. A run
249
+ * recorded only on completion loses exactly the ones worth having: the command
250
+ * that hung, and the one whose process died with the box.
251
+ */
252
+ export async function runRemoteCommand(
253
+ input: { hostKey: unknown, commandKey: unknown },
254
+ context: {
255
+ user: UserModel | null
256
+ hosts: readonly RemoteHost[]
257
+ commands: readonly RemoteCommand[]
258
+ authorizer?: RemoteAuthorizer
259
+ run: RemoteRunner
260
+ audit: RemoteAuditSink
261
+ },
262
+ options: RemoteRunOptions = {},
263
+ ): Promise<RemoteCommandResult> {
264
+ const host = resolveHost(context.hosts, input.hostKey)
265
+ const command = resolveCommand(context.commands, input.commandKey, host)
266
+ await authorize(context.authorizer, context.user, host, command)
267
+
268
+ const timeoutMs = normalizeTimeout(options.timeoutMs)
269
+ const identity = String(context.user?.email ?? context.user?.id ?? 'unknown')
270
+ const startedAt = new Date().toISOString()
271
+
272
+ await context.audit.started({ user: identity, hostKey: host.key, commandKey: command.key, at: startedAt })
273
+
274
+ const began = Date.now()
275
+ const outcome = await context.run(command.argv, { timeoutMs })
276
+ const durationMs = Date.now() - began
277
+
278
+ await context.audit.finished({
279
+ user: identity,
280
+ hostKey: host.key,
281
+ commandKey: command.key,
282
+ at: new Date().toISOString(),
283
+ exitCode: outcome.exitCode,
284
+ durationMs,
285
+ timedOut: outcome.timedOut,
286
+ })
287
+
288
+ return {
289
+ host: host.key,
290
+ command: command.key,
291
+ exitCode: outcome.exitCode,
292
+ stdout: clampOutput(outcome.stdout, options.maxOutputChars),
293
+ stderr: clampOutput(outcome.stderr, options.maxOutputChars),
294
+ durationMs,
295
+ timedOut: outcome.timedOut,
296
+ }
297
+ }
@@ -0,0 +1,58 @@
1
+ // The remote-command routes must not inherit the dashboard's local-env bypass
2
+ // (stacksjs/stacks#960).
3
+ //
4
+ // `guard()` in `routes/dashboard-api.ts` reads:
5
+ //
6
+ // if (!IS_LOCAL_ENV)
7
+ // r.middleware('auth').middleware('role:admin')
8
+ //
9
+ // and `IS_LOCAL_ENV` covers local, development, dev, test, testing and empty.
10
+ // That is a reasonable trade for operational telemetry on a developer machine.
11
+ // For an endpoint that runs commands on a server it is an unauthenticated
12
+ // command runner on every developer machine reachable on the network.
13
+ //
14
+ // Asserted against the route file as text, because that is where the mistake
15
+ // would be made - somebody adding a route next to the others and reaching for
16
+ // the helper they see used around it.
17
+
18
+ import { describe, expect, it } from 'bun:test'
19
+ import { readFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+
22
+ const routes = readFileSync(join(import.meta.dir, '../../../../routes/dashboard-api.ts'), 'utf8')
23
+
24
+ /** The route lines for this feature, whatever helper they are wrapped in. */
25
+ const remoteRoutes = routes
26
+ .split('\n')
27
+ .filter(line => line.includes('Actions/Dashboard/Remote/'))
28
+
29
+ describe('remote-command routes', () => {
30
+ it('registers both endpoints', () => {
31
+ expect(remoteRoutes).toHaveLength(2)
32
+ expect(remoteRoutes.join('\n')).toContain('RemoteCommandIndexAction')
33
+ expect(remoteRoutes.join('\n')).toContain('RemoteCommandRunAction')
34
+ })
35
+
36
+ it('never uses the guard that drops auth in local environments', () => {
37
+ // The whole point. `guard(` would mean no authentication at all under
38
+ // APP_ENV=local, on a route that runs commands over SSH.
39
+ for (const line of remoteRoutes)
40
+ expect(line).not.toContain('guard(route')
41
+ })
42
+
43
+ it('uses the guard that keeps auth in every environment', () => {
44
+ for (const line of remoteRoutes)
45
+ expect(line).toContain('authenticatedGuard(route')
46
+ })
47
+
48
+ it('the two guards really do differ, so this is not vacuous', () => {
49
+ // If `guard` and `authenticatedGuard` were the same, everything above would
50
+ // pass for a route with no protection at all. They differ in exactly one
51
+ // way: whether `auth` survives a local environment.
52
+ const guardBody = routes.slice(routes.indexOf('function guard('), routes.indexOf('function authenticatedGuard('))
53
+ const authenticatedBody = routes.slice(routes.indexOf('function authenticatedGuard('))
54
+
55
+ expect(guardBody).toContain('if (!IS_LOCAL_ENV)\n r.middleware(\'auth\')')
56
+ expect(authenticatedBody.slice(0, 400)).toContain('r.middleware(\'auth\')\n if (!IS_LOCAL_ENV)')
57
+ })
58
+ })