Files
dockhand-upstream/src/lib/server/db.ts
T
Björn Strausmann b2eb0b5dcb feat(stacks): live compose output and deploy history (#1499)
* feat(security): add exact-value redaction for compose output lines

* feat(stacks): emit compose output line by line via optional callback

* test(stacks): isolate collectProcess test from the DB init side effect

stacks.ts transitively imports db.ts and db/drizzle.ts, which run a
top-level seedDatabase() against better-sqlite3 -- unsupported under
Bun (oven-sh/bun#4290). Importing stacks.ts for a plain unit test
crashed at import time and, worse, leaked into unrelated test files
sharing the same bun test process (reproduced against
tests/semver-generic-v2.test.ts). Mock both modules' full export
surface before importing, mirroring tests/backups/route-guards.test.ts's
established mock.module(./db) pattern.

* refactor(stacks): extract collectProcess into a dependency-free core module

Testing it required importing stacks.ts, which transitively pulls in
db.ts and db/drizzle.ts -- both run a top-level seedDatabase() against
better-sqlite3, which does not load under Bun. That crash was cached
process-wide and broke an unrelated, previously-green test file
(tests/semver-generic-v2.test.ts) whenever the full suite ran.
Following the repo's established -core module pattern (nav-preferences-core,
ws-exec-core, etc.), collectProcess now lives in its own dependency-free
file and is re-exported from stacks.ts for its existing callers.

* feat(security): bridge line callbacks through redaction

makeLineForwarder wraps a raw line consumer with redactLine, dropping
withheld lines silently. This is the connector collectProcess-emitted
lines will pass through before reaching the job store.

* feat(stacks): thread onLine through the local compose execution path

executeLocalCompose, executeComposeViaHawser, and executeComposeCommand
all accept an optional onLine callback now, forwarded to whichever
execution path is chosen. The local path (executeLocalCompose) wires it
into collectProcess via makeLineForwarder, redacting against envVars and
secretVars -- deliberately not spawnEnv, which also carries PATH, HOME,
DOCKER_CONFIG and DOCKER_API_VERSION and would over-redact (HOME often
resolves to "/root", five characters, below the safe-replace threshold,
which would withhold every line containing it).

executeComposeViaHawser accepts the parameter for signature parity but
does not use it yet: its /_hawser/compose call is a single
request/response, not a stream, so it cannot report lines as they
happen. That lands in a follow-up task.

startStack, stopStack, restartStack, downStack, and deployStack gained
an onLine parameter (deployStack via a new DeployStackOptions field) and
pass it through to every executeComposeCommand call in their body.
redeployStackFromDir, removeStack, pullStackImages, pullStackService,
and updateStackService are deliberately left unchanged -- they don't run
behind the streaming job endpoints.

* feat(stacks): forward redacted compose output lines to the job store

Each of the five job endpoints (deploy/start/stop/restart/down) now
passes send as the onLine callback, wrapping each line in a
{type: 'line', line} progress event. The 'line' type distinguishes
these from the existing static {status} progress events the endpoints
already send.

* chore(tests): use English throughout, matching the repository language

* refactor(tests): rename identifiers to English

* feat(ui): readJobResponse forwards line messages to an optional callback

* feat(ui): show compose output while stack operations run

* feat(stacks): surface the final output block when an agent sends no lines

* feat(stacks): thread onLine through the create and compose endpoints

POST /api/stacks (create & start) and PUT /api/stacks/{name}/compose
(save & redeploy) both run deployStack inside createJobResponse but never
passed onLine, so no 'line' progress messages were emitted and any live
output view stayed empty until the job settled.

Both now forward lines exactly like the deploy endpoint does. A source-level
guard asserts every deployStack call under src/routes/api carries onLine —
the omission is invisible at runtime, so nothing else would catch the next one.

* feat(ui): show compose output for Create & Start and Save & redeploy

The five stack-row actions opened the output window; the stack modal's two
deploying paths did not, leaving the most common route after an edit —
Save & redeploy — with no visible progress.

The modal takes three optional callbacks and forwards to them. It holds no
output state and renders no window of its own: the loop stays on the page, so
closing the modal cannot tear down a running deploy. Callers that don't pass
them are unaffected.

* feat(hawser): route agent line messages to non-streaming compose requests

* feat(stacks): consume streamed compose lines from the agent

* refactor(logs): make LogViewer's title optional and path-safe

* fix(logs): strip the escape sequences compose actually emits

The first pattern accepted only digits and semicolons as CSI parameter bytes,
so it left private-mode sequences untouched -- including ESC[?25l and ESC[?25h,
which compose's progress display writes on every redraw. A stripper that misses
the sequences of the output it exists to clean does not do its job.

Match the CSI grammar instead: ESC [ , parameter bytes (0x30-0x3F, which is
where '?' lives), intermediate bytes (0x20-0x2F), one final byte (0x40-0x7E).

Also cover the backslash branch of the path-separator test, which the previous
case did not exercise.

* feat(stacks): show compose output in the full log viewer

* feat(stacks): summarise compose output into deploy facts

* feat(stacks): add a status line with duration to the compose output window

Measure wall-clock duration client-side (Date.now() at start, diff on
result) and format it with formatRunStatus() in ComposeOutputModal's
description: "Running…", "Succeeded · 6.2s", or "Failed · exit 1 · 0.9s".

Wires all five +page.svelte stack actions (start/stop/restart/redeploy/
down) plus StackModal's two own action paths (create+start, redeploy on
save) through the same startComposeOutput/finishComposeOutput state, so
every caller of the modal reports success/failure consistently. Without
the StackModal fix, its two actions would have reported every success as
"Failed" (ok defaulting to undefined).

exitCode is read defensively from the job result but StackOperationResult
carries no such field today, so it stays undefined until the server adds
one.

* feat(deploy-logs): add filesystem-backed log store for deploy runs

Provides appendRunLog/readRunLog/deleteRunLog/listRunLogIds/runLogPath
for per-run log files under DATA_DIR/deploy-logs. The run id is
validated (not sanitised) into a filename, so a route-parameter run id
can never resolve outside the log directory. usedBytes/budgetExceeded
compute directory usage against a 2 GiB budget for a later task to
enforce.

* fix(security): keep deploy logs out of the file browser

* test(fs-guard): stop the deploy-log test from leaking DATA_DIR

Bun runs every test file in one process. Setting DATA_DIR at module scope leaked
it into unrelated suites: selfhst-icons builds and creates its cache directory
under DATA_DIR, which fails when that points at a path the machine does not
have. Both selfhst tests went red once this file was added, while each file
passed on its own.

protectedPaths() reads the variable on every call rather than at import time, so
a plain import plus beforeAll/afterAll is enough -- the dynamic import was never
needed, and it was what made the leak module-scoped in the first place.

* fix(logs): let auto-scroll pause while the user reads earlier output

LogViewer's auto-scroll effect jumped to the bottom on every logs update
whenever autoScroll was true, with no way to opt out. In the compose
deploy output window autoScroll stays true for the whole run, so
scrolling up to read an earlier line got immediately overridden by the
next line arriving.

Restore the pause/resume behaviour the removed ComposeOutputModal
output pane used to have: track how close the viewport is to the
bottom on scroll, and skip the auto-scroll effect while the user is
more than 50px away from it. Scrolling back within range resumes it.

The auto-scroll effect's own scrollTop write triggers a 'scroll' event
too, so a flag suppresses handleLogScroll for the duration of that
write -- otherwise a fast enough stream of new lines could grow
scrollHeight again between the write and the event firing, misreading
our own jump as the user having scrolled up.

The distance check is extracted into isScrolledToBottom(), a pure
function, so it has a real test (including the exact-threshold
boundary case).

Also hide the Clear/Refresh buttons when no onClear/onRefresh handler
is supplied -- LogViewer's only current caller (ComposeOutputModal)
passes neither, so both buttons were rendered as inert dead clicks.

* feat(stacks): record only redacted output lines, never the result block

* fix(logs): lift a scroll-up pause when a fresh run starts

userScrolledUp was only ever set, never reset -- so scrolling up once
during a deploy paused auto-scroll for every later run of the same
page session, measured live: heights grew (392->1130px, 446->1130px)
but scrollTop stayed frozen once a prior scroll-up had set the flag,
even on a run nobody had scrolled during.

autoScroll going from false to true is the "a fresh run is starting"
signal (ComposeOutputModal's autoScroll={running} flips false->true at
the exact moment its parent clears composeOutputLines and starts a new
compose run) -- so that edge lifts the pause. Deliberately an edge, not
a level: reacting to autoScroll simply being true would re-lift the
pause on every single update while a run is already in progress,
defeating it entirely. shouldResetScrollPause() is a pure function
again, with a test for exactly that distinction (true -> true must stay
false) alongside the three other transitions.

* fix(logs): avoid state_referenced_locally on previousAutoScroll

npm run check flagged the previous run's seeding line (`let
previousAutoScroll = autoScroll`) as reading a prop in a plain
top-level initializer, which only captures its value once and usually
signals a bug. Leave it unset instead and let the effect seed it on
its first run, where reading autoScroll is tracked -- no behaviour
change, just where the initial read happens.

* feat(sse): hook an optional RunRecorder into both createJobResponse send paths

createJobResponse has two independent send() implementations: the JSON path
(Accept: application/json) and the streaming/job-poll path. A recorder wired
into only one of them would silently miss every deploy driven through the
other -- the UI keeps working, so nobody notices.

Add an optional third parameter, RunRecorder, with line() and end(). Both
send() closures now forward shouldRecord-qualifying lines to recorder.line();
end() is called on every path out of the operation (JSON: in a finally,
awaited before the response is enqueued; streaming: from the .then()/.catch()
that already drive completeJob/failJob, unawaited since the {jobId} response
already went out by the time the operation settles).

The interface is declared here, not built here: constructing a real recorder
needs db.ts, and importing that into sse.ts would make it unimportable under
Bun. Task 10 builds the recorder in the route and imports RunRecorder from
here. Until then both callers omit the third argument, which is exactly the
prior behaviour.

* feat(db): index schedule_executions by entity and environment

Powers per-stack/container deploy-run lookups without a full table scan.

* feat(schedules): register stack_deploy and deploy_log_reconcile types

Widen every schedule-type union that the run record (stack_deploy) and
the reconcile job (deploy_log_reconcile, upcoming) will need: the two
independent unions in src/lib/server/db.ts (ScheduleType) and
src/routes/api/schedules/+server.ts (ScheduleInfo.type, duplicated in
+page.svelte), plus SystemScheduleInfo.type in scheduler/index.ts so the
reconcile job can register as a system schedule alongside system_cleanup.

Give both types real labels in the schedules page instead of falling
into the generic system-job branches: the single-selected filter label,
the two new filter dropdown entries, and the execution detail dialog's
title chain. stack_deploy has no schedule until M3, so its entries are
inert until then -- added now so they aren't forgotten later.

Also cover the two places whose correct behaviour depends on treating
deploy_log_reconcile like the other system schedules: hiding it from
the filter list when "hide system jobs" is toggled, and rejecting an
attempt to pause/resume it through the per-item toggle endpoint the
same way system_cleanup already is (it's a global, settings-driven
schedule, not a per-item cron).

The manual-run endpoint's two type switches are deliberately left
untouched: stack_deploy has no schedule to run, and deploy_log_reconcile
has no trigger function yet (that lands with its own scheduler task).
Wiring a case now would either call nothing or reference a module that
doesn't exist.

npm run check: same 687 pre-existing errors/152 warnings before and
after (confirmed via git stash), only shifted line numbers where lines
were inserted -- no new problems introduced.

* fix(stacks): size the compose output window for build logs

The window was max-w-3xl (48rem) at 60vh. Compose and BuildKit lines are wide
-- step lines, image digests, container names -- so most of them wrapped or
needed horizontal scrolling, which defeats the point of showing them.

Widened to 90rem and 85vh, clamped with min() against the viewport so it still
fits on a laptop screen with a margin instead of overflowing.

* feat(schedules): record every stack_deploy run as a schedule_executions row

Add the run record that the rest of milestone 2 depends on: without it, deploy
logs land on disk with nothing pointing at them, execution history stays empty,
and the upcoming reconciler would treat every log file as orphaned and delete it.

deploy-run-record-core.ts (buildRunDetails, importfree) assembles the details
JSON -- options, summary, exit code, compose/env hash, log file name -- and
refuses to build it without a log file name, since that name is what ties the
record to its file. deploy-run-record.ts (createRunRecorder) is the
database-touching half: creates the schedule_type='stack_deploy',
schedule_id=0 row up front, chains line() calls onto appendRunLog so
concurrent writes can't scramble the log's order, and closes the row in
end() -- which must work even when no line ever arrived, since a deploy can
fail before compose produces any output.

Also switches on deploy-log-store's total-size budget (defined in an earlier
task, never called anywhere until now): once a run's log crosses it, further
lines are dropped, the file gets a line saying so, and details.truncated is
set.

Wires the recorder into both places that call deployStack from an HTTP
handler: the dedicated deploy endpoint and stack-create-with-immediate-start.
Both now read/compute the compose and effective env up front so a compose
hash, env hash, and the schedule_executions row all exist before the SSE job
stream opens -- createJobResponse takes the recorder as an already-resolved
value, not something it can await for itself.

stacks.ts gains an exitCode field on StackOperationResult, populated by the
local/direct compose path (which already had the number, just wasn't
returning it) and left unset on a timeout or over Hawser (protocol doesn't
carry one) -- the recorder falls back to a value consistent with success/
failure when it's missing.

bun test tests/: 2828 pass, 0 fail (baseline 2826 + this task's 2 new tests).

* fix(schedules): keep the deploy summary in sync with what actually got written

line() pushed its argument onto the in-memory buffer synchronously, but only
queued the file write onto the async chain. Called several times before the
chain gets to run any of them (the ordinary case -- compose output arrives in
bursts), a line already sitting in the buffer could still get skipped by the
file write once truncation kicks in partway through the queue, leaving the
eventual summary counting a line that was never actually persisted.

Move the push into the same guarded step as the write so both share one
fate: a line only ever ends up in the summary if it also ended up in the
file. Caught by re-reading the chaining logic after writing it, not by a
test -- verified instead with a live run against a real SQLite db (tsx,
temp DATA_DIR) confirming the three-line/no-line lifecycles still land
correctly. bun test tests/: still 2828 pass, 0 fail.

* feat(stacks): add deploy-run endpoints (list, single, log, delete)

Three routes under api/stacks/[name]/deploys/ each check stacks:view/edit for
themselves -- they do not inherit /api/jobs/{id}'s no-auth model. A shared
loadOwnedDeployRun helper confirms a run actually belongs to the requested
stack and checks environment access against the run's OWN environmentId
(never a caller-supplied env query param), closing the same cross-env bypass
class backups/route-guards.ts already closed for snapshot/config lookups.

getScheduleExecutions gained an entityName filter, the actual consumer of the
(entity_name, environment_id) index from the previous commit.

Also: extracted tests/helpers/db-fake.ts, a shared mock.module() registration
point for $lib/server/db, and moved tests/backups/route-guards.test.ts onto
it. Two test files each wholesale-mocking that module collided (confirmed:
whichever wins clobbers the other's needed export, deterministically) -- the
new helper avoids that by pre-declaring every export name any test needs as a
stable dispatcher, so later-registered implementations are still reachable
regardless of which file's import resolves the specifier first.

* feat(schedules): reconcile deploy-log files against stack_deploy records

Adds a dedicated deploy_log_reconcile schedule, modelled on system-cleanup.ts's
static jobs (own cron + enabled setting, own schedule_executions row) rather
than tacking it onto system_cleanup: the operator can see and disable it
independently.

planReconcile() is a pure decision core: a file with no matching record is
deleted (an orphan with nothing left to explain it); a record with no file is
only marked via details.logMissing, never deleted, since the run metadata
(when, who, what was built) stays valuable once the log text is gone. The
asymmetry is the point -- over-eager deletion here would destroy exactly the
record this job exists to protect.

run/+server.ts gained a deploy_log_reconcile case in both switches (env
resolution and dispatch) -- without it every manual trigger 400s, since both
switches already have a default that rejects unknown types.

ASSUMPTION flagged in deploy-log-reconcile.ts: the record side reads
schedule_executions rows with scheduleType 'stack_deploy' and treats
String(id) as the deploy-log run id. Nothing in this branch creates such a
row yet -- that is Task 10's createRunRecorder, not landed here (confirmed:
no t10/done ref exists). This is read-only against the existing, already
scheduled ScheduleType union, not a substitute for Task 10's write path, but
it is unverified against Task 10's actual output and needs a look once that
lands.

triggerDeployLogReconcile() is a separate function rather than a branch in
triggerSystemJob(): that function dispatches by a flat, type-less jobId
string, and DEPLOY_LOG_RECONCILE_ID (1) would otherwise collide with
SYSTEM_SCHEDULE_CLEANUP_ID (also 1, different scheduleType).

* fix(schedules): don't mark an in-progress deploy's record as logMissing

Read deploy-run-record.ts directly (Task 10's real code, in a sibling clone
not yet merged onto this branch) to check the assumption flagged in the
previous commit -- and it confirmed the record-id linkage is exactly right
(scheduleType 'stack_deploy', String(schedule_executions.id) as the deploy-log
run id), but also surfaced a race this job didn't guard against: the row is
created with status 'running' before the deploy has written a single line,
and the log file only comes into existence on the first line() call. A
reconcile firing in that window would mark an actively-running deploy as
logMissing, and nothing here ever re-checks or clears that flag once the file
does appear -- so the record would carry a false "log missing" permanently.

getScheduleExecutionIdsByType() now also returns status; records still
'queued'/'running' are excluded from markMissing candidacy (a later run will
catch them correctly once the deploy has actually finished) while remaining
in recordIds, so an already-written file for them stays protected from
deletion either way.

* test(schedules): extract the in-progress guard into a tested pure function

isEligibleForMissingMark() replaces the inline NON_TERMINAL_STATUSES array
check in deploy-log-reconcile.ts -- same decision, now importfree and covered
by its own tests (both non-terminal statuses excluded, every terminal status
included), with a mutation counter-test confirmed to go red for the right
reason before being reverted.

* fix(stacks): keep compose output visible below the editor on deploy

Save & redeploy and Create & Start used to hand live output off to a
separate window and close the stack modal immediately, so the editor
and the output were never visible at the same time -- forcing the user
to close the output window just to get back to the compose they were
editing.

The modal now keeps its own output state (lines, running, ok, ms, exit
code) and renders it inline below the editor instead of forwarding to
the page's shared ComposeOutputModal. Whether the modal closes after a
save is now a small pure function, shouldCloseAfterSave(deployed): a
plain save still closes, a deploy stays open so the output stays
visible.

Also fixes a pre-existing bug in the same code path: the error branch
called finishOutput(undefined, false) unconditionally, clobbering the
real ok/exitCode a prior successful finishOutput call had already
recorded when the job itself reported failure. Now guarded on
outputRunning so a real result from the server is never overwritten.

GitStackModal.svelte does not use the onOutput* props and needed no
changes.

* fix(stacks): clear docked deploy output when the modal reopens

StackModal is persistently mounted -- one instance is reused for every
stack. The new output* state introduced for the docked deploy output
was only ever assigned in startOutput(), so it survived close/reopen
just like the pre-existing compose-validate state does. Reopening the
modal for a different stack could show the previous stack's title,
lines and "Succeeded"/"Failed" status under the new stack's editor,
which reads as if something just ran for it.

Reset all seven output* fields in the same open-effect block that
already clears validate panel state on each open, for the same reason.

A reopen of the *same* stack while its own deploy is still running
loses the lines gathered so far, but the poll loop in sse-fetch.ts
keeps running regardless of the modal's open state (same as
ComposeOutputModal), so new lines resume arriving within one poll
interval; and if the job finishes before any new line does,
finishOutput's existing "outputLines.length === 0 && output" fallback
refills the whole transcript from the job's final output. That brief,
self-correcting gap was judged preferable to a stale result silently
misattributed to the wrong stack.

* fix(deploys): require env on the list endpoint to stop cross-environment leaks

GET /api/stacks/[name]/deploys treated `env` as optional. Omitting it left
envIdNum undefined, which skipped the enterprise environment-access check,
made auth.can() evaluate the caller's globally-merged permission instead of
their permission for one specific environment, and dropped the environment
filter from getScheduleExecutions() entirely -- returning stack_deploy runs
(timestamps, status, error text, details.userId) from every environment that
happens to have a same-named stack, regardless of what the caller's role
actually grants.

A stack name alone doesn't identify one environment, so there's no correct
behavior for a missing `env` beyond rejecting the request: parse and validate
it before the permission check runs, so auth.can() and canAccessEnvironment()
always see a definite id. Sibling routes under .../deploys already close this
class via loadOwnedDeployRun (env derived from the loaded run); this was the
one route deriving it from an optional query parameter instead.

* fix(schedules): isolate per-element failures in the reconcile job

Reproduced independently: one throwing deleteRunLog/updateScheduleExecution
call aborted the whole run -- and since the failing element is never
removed/marked, every LATER run failed at the exact same element again,
permanently. A harmless orphan file ordered before the failing one was still
removed; everything after it was not; the failed run's own `details` came
back null, so the partial progress wasn't even visible.

Both loops now wrap each element in its own try/catch (same shape as
scheduler/index.ts's scannerCleanupAllEnvs, which does this per-environment
for the same reason). Failures are counted and logged individually instead
of aborting.

Counts now reflect what actually happened, not what was planned:
deletedFiles/markedRecords only increment on success, plus a new `failed`
count. Previously markedCount was derived from plan.markMissing.length minus
skippedInProgress -- correct only by coincidence, since nothing before this
commit could fail mid-loop; a failing element would have inflated it to the
planned size.

A run with per-element failures is recorded as 'warning' rather than
'success' -- this repo's existing convention for "completed with a caveat"
(see the ScheduleStatus doc comment in db.ts), not 'failed', which stays
reserved for the run itself throwing (the outer catch, unchanged).

Tests (tests/deploy-log-reconcile-job.test.ts, new): one per loop, each with
a mutation counter-test confirmed to fail for the right reason (the element
after the failure was unreached; the reported count matched the plan instead
of the outcome), then reverted. $lib/server/db faked via
tests/helpers/db-fake.ts's shared registry (extended with the five exports
this job needs); $lib/server/deploy-log-store mocked directly since no other
test file claims it.

* fix(sse): reuse endFromResult on the streaming path so a failed run is recorded as failed

A failed docker compose run sends result:{success:false,error} and returns
normally -- it never throws. On the fire-and-forget streaming path (no Accept
header, the path every browser call takes), the .then() branch hardcoded
recorder.end(true) regardless of the result payload, so every such run was
persisted with status:"success" and errorMessage:null.

The JSON path already derives ok/error from the held-back result via
endFromResult(); reuse it here instead of duplicating success/failure logic,
using the result line the .then() branch already reads for completeJob().

* fix(deploy-run-record): redact known secret values out of the stored error text

DeployRunRecorder.end() wrote result.error verbatim into schedule_executions'
errorMessage/details on a failed run. That string is compose's raw, unredacted
stdout/stderr (deploy-recorder.ts's shouldRecord() deliberately excludes the
'result' event from the log file for this exact reason). GET
/api/schedules/executions returns errorMessage/details to every authenticated
user, with no stack/environment permission check -- so a secret that leaked
into compose's error output was readable across permission boundaries.

createRunRecorder() now takes the same merged secrets list its callers already
build for envHash, and end() runs the error text through the same redactLine()
the streamed lines already use before storing it. When redactLine withholds
the whole line (a too-short value matched), the row still closes "failed" with
a safe placeholder instead of no message at all -- a run stuck as "running"
forever is worse than one with a generic error.

* fix(stacks): auto-close after a successful deploy, stay open on failure

Operator decision (30.08.2026), after seeing the docked output live:
always keeping the modal open post-deploy was as wrong as always closing
it used to be. A plain save still closes as before. A deploy that
succeeded now closes too, just after a longer delay so the result is
still glimpsed. A deploy that failed keeps staying open.

shouldCloseAfterSave(deployed: boolean): boolean is replaced by
saveCloseTiming(deployed: boolean, ok: boolean): 'close' | 'close-delayed'
| 'stay-open', so the three operator-specified outcomes are each
individually representable and testable, instead of collapsing "closes"
into one boolean that couldn't distinguish "right away" from "after a
flash".

Verified there is no path at the two call sites where a failed deploy
resolves without throwing first: both POST /api/stacks and PUT
/api/stacks/{name}/compose always send an explicit `result` event with
success:false on failure, with an explicit `return` right after it, so
neither send() call falls through to the success path afterwards. The
'stay-open' branches at both call sites are therefore dead code today,
kept only so the switch stays exhaustive if that ever changes.

* feat(stack-modal): make the live output panel's height resizable

The output panel below the compose editor had a fixed height. Add a
horizontal drag divider (mirroring the existing vertical compose/env
split) so the panel can be resized between 15% and 70% of the combined
editor+output area, persisted to its own localStorage key.

Extract the clamp used by both splitters into clampSplitRatio() and fix
a latent bug along the way: Math.max(min, Math.min(max, value)) does
not clamp when value is NaN (Math.min/Math.max propagate NaN), which
happens for real when a container's measured size is briefly 0 during
a drag. The existing compose/env splitter had the same bug and now
uses the same fixed clamp.

* fix(stacks): record Save & redeploy (compose PUT) as a stack_deploy run

PUT /api/stacks/[name]/compose with restart:true redeployed the stack but
never built a run recorder, unlike the dedicated deploy endpoint and
stack-create-with-immediate-start. After a successful redeploy the modal
now auto-closes (recent change), so this was the only path where the
output of a successful deploy was fully lost -- no schedule_executions
row, no log file, nothing to look back at.

Wires createRunRecorder in identically to deploy/+server.ts: switches the
authoritative-paths lookup from getStackComposeFile to requireComposeFile
(same call, now also resolving secretVars/nonSecretVars), hashes the
just-submitted `content` (not a re-read off disk -- saveStackComposeFile
already wrote this exact string, and it's the same string handed to
deployStack), and hashes the merged effective env for envHash. Skipped
entirely when requireComposeFile unexpectedly fails right after a
successful save, so a run is never recorded with an empty secrets list.

restart:false (save only) is untouched -- no recorder is built, no row is
created.

Adds tests/helpers/authorize-fake.ts, mirroring db-fake.ts's dispatcher
pattern, and moves tests/deploy-endpoints.test.ts's inline authorize mock
onto it -- a second direct mock.module('$lib/server/authorize', ...) in
the new test file would have collided with that file's existing one.

bun test tests/: 2878 pass, 0 fail (baseline 2870 + this task's 8 new
tests). npm run check not yet run.

* fix(stack-modal): clamp the validate-panel width the same way, and rename accordingly

validatePanelWidth = Math.max(320, Math.min(560, w)) had the same NaN
gap as the two split-ratio clamps fixed earlier: if editorRowRef's
measured width is briefly 0 during a drag, w becomes NaN and the
"clamp" stays NaN.

clampSplitRatio never assumed a percentage, only value-vs-bounds
ordering, but its name did -- applying it to a pixel width buried that
under a misleading name. Renamed it (and its module/test file) to
clampNumber, with a doc comment that says plainly it works for both
percentage ratios and pixel distances, and a dedicated test proving
the pixel bounds (320-560) clamp exactly like the percentage bounds.

* feat(stack-modal): add a Deploys tab showing recorded run history

Adds a third history-style tab next to Backups: each recorded
stack-deploy run (GET /api/stacks/{name}/deploys, env required) as a
collapsed row -- status, relative time, duration, and a one-line
container/build summary -- expandable for the full picture: container
counts, whether the build came from cache or not, images built/pulled,
the deploy options used, a truncated-log notice, and on failure the
shortened last error line.

All of that text composition lives in deploy-run-view.ts as pure,
import-free functions (mirrors deploy-summary-core.ts's own reasoning:
no $lib/server import, so the test file runs under Bun without
dragging in better-sqlite3). The one case load-bearing enough to name
in its own doc comment: a run with no summary at all (compose produced
zero output lines) must read as "unknown", never silently as "zero
containers, nothing built" -- those are different facts.

No component test exists for DeploysPanel.svelte itself -- this repo
has no Svelte component test harness (no @testing-library/svelte, no
jsdom; @playwright/test is a dependency with zero test files). Manual
verification only for the component; the 37 new unit tests cover every
branch of the text-composition layer instead.

* perf(deploy-log-store): stop re-scanning deploy-logs/ on every appended line

DeployRunRecorder.line() called budgetExceeded() (a full readdir+stat over
every file in deploy-logs/) after every single line, so a deploy's cost grew
with both its own output AND the number of past runs' log files on disk.
Measured: 200 lines against 500 existing files took ~16.2s; against 50 files
~1.8s; with the scan removed entirely, ~49ms.

Replace that per-line full scan with SizeBudgetTracker: the directory total
excluding this run's own file is measured once, lazily, and cached; each
appended chunk's byte length is added to an in-memory running total (no
stat() involved); the cache is only re-measured once the run's own output
crosses 1 MiB since the last measurement. The budget itself is unchanged
(2 GiB, checked after each write per design doc §5) -- only how often the
directory gets re-scanned to check it.

This trades exactness for speed under concurrent writers: a run started
right after this run's baseline was measured is invisible to this run's
budget check until the next refresh. Documented on SizeBudgetTracker as an
intentional trade-off -- BUDGET_BYTES exists to catch a filled disk, not to
enforce a precise quota, and the old per-line scan had no locking against
concurrent appendRunLog() calls either.

Tests: SizeBudgetTracker's budget math (under/over/already-over-before-first-
byte), its refresh cadence (scans once for 200 small appends where the old
code would have scanned 200 times, and again once a single big append
crosses the refresh threshold), and the documented concurrent-writer gap.
Plus a DeployRunRecorder-level truncation test via an injected tiny-budget
tracker (SizeBudgetTracker now takes optional budgetBytes/measureOthers
params, DeployRunRecorder now takes an optional sizeBudget -- both test-only
seams, unused by production callers).

Refs branch-review.md H2.

* feat(stack-modal): load and show a deploy run's log on expand

Expanding a Deploys-tab row now fetches GET .../deploys/{id}/log into a
LogViewer, lazily -- never while the list itself loads. A run already
marked logMissing by deploy-log-reconcile skips the fetch entirely
instead of making a request that would just 404.

buildDeployLogPanelState (deploy-run-view.ts) turns the run into the
three independent facts the panel needs: whether the log is missing,
whether it was truncated, and whether the run has settled enough to
allow deleting it -- so a missing log never renders as an empty
viewer, and no delete action is offered while a run is still running.

* fix(deploys): scope stacks:view/edit permission checks to the run's own environment (H1)

GET/DELETE .../deploys/{runId} and GET .../deploys/{runId}/log checked
auth.can('stacks', 'view'|'edit') without an environmentId, which falls back
to the caller's globally-merged permission (OR across every role, per every
environment). A caller with stacks:view/edit scoped to environment A could
therefore read, or delete, a deploy run belonging to environment B, as long
as they also had SOME role granting stacks:view/edit somewhere -- and
loadOwnedDeployRun's own canAccessEnvironment check does not close this,
since it only confirms "has some role for this environment", not "has
stacks:view/edit specifically for this environment".

Add a second, run-scoped check via can('stacks', <action>, run.environmentId
?? undefined) AFTER loadOwnedDeployRun resolves the run -- in addition to,
not instead of, the existing un-scoped gate, so the pre-existing "no
permission anywhere" 403 tests keep their exact prior behavior. This is the
same derive-environment-then-check pattern api/git/stacks/[id]/+server.ts and
its env-files route already use for their own nullable environmentId. The
sibling list route (deploys/+server.ts) already documents and closes this
exact bug class for itself; this closes it for the three single-run routes
too.

Regression tests confirm the scoped check is what closes the gap: reverting
just the two source files turns the three "denied" tests red with the
pre-fix symptom (200 instead of 403), while the "allowed" tests stay green.

* fix(deploys): list route treats the local environment (env omitted) as reachable, not a 400 (H3)

On a single (local-only) environment install -- the normal case -- every
deploy run is recorded with environment_id = NULL: createRunRecorder()
writes it that way whenever the triggering request carried no envId, which
is always true for the local environment, because appendEnvParam()
($lib/stores/environment.ts) only appends an `env` query param when envId is
truthy. GET .../deploys required `env` as a parseable integer and 400'd
otherwise -- and there was no integer a caller could pass that matched a
NULL row (env=0 included; environment ids are never 0). The result: on a
fresh, single-environment Dockhand install, EVERY recorded deploy run was
completely unreachable through this endpoint.

The data layer already supported this -- getScheduleExecutions() maps
`environmentId: null` to `isNull(...)` -- only this route couldn't express
"give me the null-environment runs" at all.

Fix: two `env` shapes now both resolve to `environmentId: null` ("the local
environment") -- the parameter OMITTED entirely (the shape the UI actually
sends), and the literal string "null" (for direct/curl callers). Anything
else must still parse as an integer; a genuinely invalid value is still a
hard 400, so "list runs, unspecified environment" never silently means "all
environments merged". The permission check maps the null case to
`can('stacks', 'view', undefined)` -- the same "no environment context"
global-permission check api/git/stacks/[id]/+server.ts already uses for ITS
nullable environmentId -- so a caller still needs stacks:view somewhere to
see local-environment runs; canAccessEnvironment() is skipped for null (there
is no environment record to check access against).

Test coverage updated in the same file the pre-fix "missing env -> 400" and
"cross-environment leak" tests lived in, since both encoded the very
behavior this fixes (the leak test now confirms the null filter itself
still excludes a different, non-null environment's runs -- the security
property that motivated requiring `env` in the first place is unchanged).
Reverting just the route file turns the two H3-labeled tests red with the
pre-fix symptom (400 instead of 200).

* fix(openapi): move loadOwnedDeployRun-produced 400/404 into prose, closing the CI status-code-drift gate (B1)

`npm run generate:openapi:check` (.github/workflows/openapi.yml:49) failed
with 5 Gate-4 (status-code drift) issues: GET/DELETE .../deploys/{runId}
documented resp-400 and resp-404, and GET .../deploys/{runId}/log documented
resp-400, but all three of those specific codes are produced by the SHARED
loadOwnedDeployRun() helper (deploy-run-access.ts) -- not by a literal
`status: 400` / `error(400)` in the handler's OWN body text, which is all
scripts/generate-openapi.ts's static scanner (analyzeHandlerBody) can see.
statusDrift > 0 is a hard failure.

Fix: move that information into the `description:` field as prose instead of
resp-400/resp-404 lines -- the same pattern already used by
POST /api/backup/destinations/{id}/task for its own guard-produced codes.
Nothing is undocumented: the codes and their meaning are still explained,
just not as a formal schema-level response entry the generator expects to
find literally in the handler it's annotating. The log route's resp-404 line
stays as-is (its "Log not found" 404 IS literal in that handler's own body,
alongside loadOwnedDeployRun's 404 which is now covered by the same prose).

Also regenerates src/lib/openapi.generated.json and static/openapi.json
(--check writes both as a side effect of running) -- picks up this fix plus
unrelated, already-existing drift from earlier commits on this branch that
had never been regenerated (the deploy_log_reconcile schedule type additions
to two already-annotated schedule endpoints).

Verified: `npm run generate:openapi:check` was Exit 1 ("Hard failures: 1",
the 5 issues above) before this commit, at this branch's HEAD, in a fresh
clone with `npm install` -- Exit 0 ("Hard failures: 0") after.

tests/deploy-run-openapi-drift.test.ts reproduces Gate 4's own drift check
directly against these three routes' annotations (via the same
parseAnnotations/splitMethodBodies/analyzeHandlerBody the generator itself
uses) rather than shelling out to the full generator, which scans and
rewrites the entire route tree for what is a narrow, three-file regression.
Reverting just the two doc-comment edits reproduces the exact stale-code
sets from the CI report (["400","404"] and ["400"]).

* fix(stack-modal): stop duplicating a failed run's full error text

The expanded row's error box rendered the raw errorMessage (compose's
full stderr) verbatim, right above the new log panel from this same
task -- for a failed run, that meant showing almost the same text
twice. Task 17's own spec called for the shortened last error line
here (the same text the collapsed row already truncates to), not the
raw message; the box now shows view.errorSummary instead. The full
text has exactly one place now: the LogViewer beneath it.

* fix(stack-modal): fetch deploy runs for the local environment too

envId === null is not "no environment selected" -- per the list route's
H3 fix (.../deploys/+server.ts), it IS the local environment: a
single-environment install's own deploy calls never send an env param
either (appendEnvParam only appends one when envId is truthy), so
every one of its runs is recorded with environmentId === null, and the
route now resolves an omitted env to exactly that filter.

The guard that skipped the fetch and showed 'No environment selected.'
predates that fix, from when the endpoint hard-required env and would
400 without it. With the endpoint now accepting the omitted shape, the
guard silently kept the tab empty on the most common install shape
even though the API would have answered. Removed -- onMount fetches
unconditionally, appendEnvParam already produces the right URL for
both the local (null) and a specific (numeric) environment.

No pure-function decision to extract here: the branch this replaced
IS the fix, not a rule with two live outcomes -- appendEnvParam
already forms the correct request for both envId shapes, so there is
nothing left in the component to test beyond what deploy-endpoints.
test.ts already covers at the route.

* feat(git-stacks): record stack_deploy runs for every git-stack deploy trigger

deployGitStack() is the single function every git-stack deploy trigger funnels
through -- the manual Deploy button, a config-update-then-redeploy, both webhook
methods, create-and-deploy, and (via runGitStackSync) the cron scheduler and the
schedules page's "run now". Wire the stack_deploy run recorder in there, not at
a single HTTP route: wiring it only at the route level (the way compose PUT and
the non-git deploy endpoint already do it) would leave every other caller --
crucially the cron- and webhook-triggered ones, the truly unattended runs the
deploy-verlauf design doc's Meilenstein 5 is about -- unrecorded.

composeHash is taken from the content actually synced from git; envHash/secrets
mirror the same (deliberately approximate) DB-var resolution the non-git routes
already use. Every real call site now passes an explicit triggeredBy (manual/
webhook/cron) instead of the previous unconditional default, and the manual
routes forward auth.user?.id and an onLine callback so SSE progress streaming is
unchanged for HTTP callers.

Added tests/helpers/stacks-fake.ts (mirroring db-fake.ts's collision-safe
registration pattern) since syncGitStack -- deployGitStack's sync step -- is a
same-module collaborator that can't be faked via mock.module(), so the new test
drives a real git sync against a local repo served by a real `git daemon`
subprocess, faking only the $lib/server/stacks boundary (deployStack). Migrated
the existing compose-redeploy test to the same shared helper, since a second
direct mock.module('$lib/server/stacks', ...) call would have collided with it.

* docs(backups): note that a restore's redeploy has no stack_deploy run record

Backup restore's redeploy calls redeployStackFromDir directly, bypassing the
deployGitStack()/compose-route path createRunRecorder is wired into -- so it
gets no stack_deploy row and no log file. Left unrecorded on purpose (its own
piece of work, no structural blocker), but that was invisible in the code: no
comment, no marker, nothing to distinguish it from an oversight for a reader
who doesn't already know the history.

* fix(git-stacks): coerce forceRecreate to boolean for the run recorder's options

syncResult.updated is boolean | undefined; DeployRunOptions.forceRecreate wants a
strict boolean. Caught by a differential tsc run against the parent commit (the
only genuinely new type error introduced by the previous commit -- everything
else tsc reported was pre-existing, just line-shifted).

* fix(api): thread build/pull/forceRecreate through stack create & redeploy

POST /api/stacks and PUT /api/stacks/[name]/compose always deployed with
build:false and no pullPolicy, regardless of what the request sent -- a
stack whose compose declares a build: section silently never rebuilt on
"Create & Start" or "Save & redeploy" (the saved compose change just ran
against the stale local image). Both endpoints now accept pull, build,
and forceRecreate in the request body and forward them to deployStack()
and the run recorder, matching the contract the dedicated deploy endpoint
(POST /api/stacks/[name]/deploy) already uses.

PUT .../compose defaults forceRecreate to true when the field is omitted,
preserving its prior hardcoded behavior for a caller that has not been
updated to send it explicitly (env var changes need --force-recreate to
take effect). POST /api/stacks (Create & Start) has no such compatibility
concern -- there is nothing to recreate on a brand new stack -- so it
defaults all three to false when omitted.

pull:false intentionally leaves pullPolicy undefined rather than mapping
to a "never"/"missing" policy: deployStack() treats an undefined
pullPolicy as "do not pull", and its own post-deploy pending-update badge
reconciliation is gated on the same field, so unchecking Pull images also
means a stale "update available" badge on that stack will not clear until
the next pull. Documented at both call sites, not just left implicit.

Regenerated static/openapi.json and src/lib/openapi.generated.json for the
new pull/build/forceRecreate body fields on both @openapi blocks.

* feat(stacks): let Save & redeploy / Create & Start pick pull/build/force-recreate

"Save & redeploy" and "Create & Start" in the stack edit dialog silently
never rebuilt a service with a build: section (see the paired API commit).
Fixing the endpoints alone isn't enough without a way for the operator to
actually ask for a build -- both buttons are now wrapped by RedeployPopover
instead of deploying immediately, offering the same Pull/Build/Force
recreate checkboxes already used for redeploying from the stack grid.

RedeployPopover gains:
- triggerVariant="button" + triggerClass, so it can render as a full
  labeled trigger styled like the shadcn Button component (buttonVariants)
  instead of only the small icon-only trigger the stack grid rows use.
- defaultPull/defaultBuild/defaultForceRecreate + reason, re-seeded into
  the checkboxes on every open (not just once at mount) so a caller whose
  default tracks live content -- "does the compose have a build: section
  right now" -- stays correct across repeated opens.

"Build images" is pre-checked whenever the compose file declares a build:
section for any service (compose-build-detect.ts, extracted as a plain
function with its own test rather than left inline in the component, the
same way save-close-policy.ts is). "Pull images" defaults to unchecked for
both buttons (unlike the grid's redeploy popover, which defaults it on) --
Save/Create already run against whatever images are already local, pulling
is an explicit extra step here. "Force recreate" defaults to checked for
Save & redeploy only, preserving its prior always-on behavior now that the
API also accepts a caller opting it off; Create & Start defaults it off
since there is nothing to recreate on a brand new stack.

pendingSaveOptions carries the chosen options through the path-change
confirmation dialog the same way pendingSaveRestart already does, so
re-entering handleSave() after the operator confirms a compose/env file
move still deploys with what they actually picked in the popover.

* chore: drop internal planning-doc references from comments

Comments and test descriptions referenced an internal milestone label and
design-doc section number that mean nothing to anyone outside this repo's
own planning process. Reworded to be self-contained -- no behavior change.

* refactor(stacks): split "Save & redeploy" / "Create & Start" into main + arrow

The previous shape had the whole button open the popover before deploying
anything -- correct behavior, wrong ergonomics. The common case (just save
and redeploy, or just create and start, with sane defaults) now takes one
click again: the label is a real, directly clickable button. A separate
small chevron button to its right opens the same Pull/Build/Force recreate
popover for the cases that need it. Two independently focusable <button>
elements sitting flush together, not one element whose behavior depends on
click position.

RedeployPopover's triggerVariant switches from 'button' (removed, was a
label-plus-icon trigger wrapping the whole action) to 'chevron' (a small
square trigger meant to sit attached to a separate main button the caller
renders itself -- the popover no longer owns the main button at all).

saveRedeployDefaults/createStartDefaults in StackModal are the single
source of truth for "what a direct click deploys with", shared between
each main button's own onclick and the popover's defaultPull/defaultBuild/
defaultForceRecreate props next to it, so the one-click path and the
pick-then-Deploy path can never quietly diverge on what "the default"
means.

The two existing icon-only redeploy triggers in the stack grid (RedeployPopover
used there via the unchanged 'icon' variant) are untouched -- they were
already the "click opens a menu" shape by design, since there is no separate
main button in a table row to split off from.

* feat(logs): follow the caller's own theme in LogViewer

LogViewer defaulted to a hardcoded dark theme regardless of the
editor's own light/dark toggle. Add a theme prop (defaulting to
'dark', so any caller that doesn't pass it renders unchanged) and
wire it through the two places that already have an editor theme in
scope: StackModal's docked output panel and, forwarded through a new
theme prop on DeploysPanel, the Deploys tab's expanded run log.

ComposeOutputModal has no editor in scope (opened straight from the
stack list) — its Dialog.Content already follows the app-wide
light/dark switch via bg-background, so it reads that switch once on
mount instead, mirroring the existing pattern in ComposeGraphViewer's
graphTheme.

Search-match highlighting gets a light-mode override, scoped to
.log-viewer-light so it doesn't touch the shared (unscoped)
.search-match rule other components such as LogsPanel still rely on:
the base rule's near-white text is illegible against the pale amber
highlight once the panel itself goes light.

* fix(stacks): pass DeploysPanel's theme prop under its own name

{editorTheme} is Svelte's shorthand for a prop literally named
editorTheme -- DeploysPanel's Props interface declares theme, not
editorTheme, so this silently passed an unknown prop and left theme
at its 'dark' default. svelte-check caught it: 'editorTheme' does not
exist in type 'Props'. bun test doesn't type-check props, which is
why it stayed green through the first commit.

* fix(sse): guard RunRecorder.end() against a second call if it throws

createJobResponse's fire-and-forget path calls recorder.end() in its
.then() branch. If that call itself throws, the rejection lands in
.catch(), which unconditionally called recorder.end() again -- breaking
the RunRecorder contract that end() runs exactly once.

Track whether the .then() branch already reached its end() call and
skip the .catch() call when it did, regardless of whether end() itself
resolved or rejected.

* test(fs-guard): remove redundant DATA_DIR assignment before the import

isProtectedPath() only reads process.env.DATA_DIR lazily inside its own
call, not at module-import time, so the beforeAll/afterAll pin already
in this file is sufficient on its own -- the extra assignment right
before the dynamic import did nothing.

Verified by measurement, not assumption: this file passes standalone,
paired with selfhst-icons.test.ts in both file orders, and as part of
the full suite, all without the removed line.

* refactor(components): move LogViewer out of the logs route

LogViewer.svelte lived under src/routes/logs/ but was never used by
that route (LogsPanel.svelte and +page.svelte render container/system
logs their own way). Its only three consumers -- ComposeOutputModal,
StackModal, and DeploysPanel, all under src/routes/stacks/ -- imported
it cross-route with a relative path, which is what shared components
under src/lib/components/ exist to avoid.

Moved it there and repointed all three imports at the $lib alias. No
internal change: every import inside LogViewer.svelte already used
$lib/... aliases, none were relative to its old location.

* test(types): fix svelte-check type errors in two test files

deploy-run-record-core.test.ts: d.summary is DeploySummary | undefined
on RunDetails, so d.summary.containersStarted possibly-undefined'd.
Switched to optional chaining -- the assertion stays just as strong
since expect(undefined).toBe(2) still fails, the RHS is a hard literal.

deploy-run-view.test.ts: both test.each((input, expected) => ...)
callbacks had their params implicitly typed 'any'. Both formatTrigger
and formatStatusLabel take/return plain strings, so annotated both
params 'string', matching the explicit-annotation style test.each
already uses elsewhere in this repo (stack-filename.test.ts).

* feat(settings): make deploy-log reconcile schedule configurable

setDeployLogReconcileCron/setDeployLogReconcileEnabled existed in db.ts
with no caller outside of it and the scheduler startup path -- there was
no way to disable or reschedule the daily reconcile job (which deletes
orphaned deploy-log files) short of writing to the settings table by
hand. Wire both into the general settings GET/POST route, the client
settings store, and a new toggle+cron block on the General settings
tab, following the existing scannerCleanupCron/scannerCleanupEnabled
pattern in the same files.

refreshSystemJobs() already read these getters before this change; it
just never had anything to react to.

* fix(format): read naive server timestamps as UTC in formatRelativeTime

formatRelativeTime() parsed its input with a bare `new Date(...)`, so a
naive PG timestamp (e.g. "2026-08-30 20:36:11.534" - no "Z"/offset, the
form Drizzle hands back for a `timestamp without time zone` column) got
read as browser-local time instead of UTC. A deploy from a couple of
minutes ago rendered as "2h ago" for anyone outside UTC.

date-format.ts already solved exactly this for formatDateTime() via
parseTimestamp() (#1183), but formatRelativeTime() lives in the
import-light utils/format module and was never switched over, even
though every caller passes the same raw server value to both functions
side by side.

Switch formatRelativeTime() to parseTimestamp() as well. Behavior is
unchanged for Date instances, epoch numbers, and already-zoned strings;
only the previously-mishandled naive-string case is fixed.

Added a TZ-forced (America/New_York) test block that reproduces the
bug and verifies the fix; reverting the change makes those three tests
fail with "in the future" (a 4h forward misinterpretation), confirming
they fail for the right reason.

* fix(tests): inject the reconcile job's log-store calls instead of mocking the module

mock.module() freezes a module's export shape for the whole test process, not
just the file that calls it. The reconcile job's test replaced
'$lib/server/deploy-log-store' with a two-export factory, so every module that
resolved the same specifier afterwards lost the other eight -- deploy-run-record.ts
imports appendRunLog, runLogFileName and SizeBudgetTracker from there and threw
"Export named ... not found".

Whether it triggered depended purely on the order bun walks tests/, which is why
the suite was green locally and red in CI.

The test's own comment reasoned that no other test file claimed that specifier.
That was the wrong question: the collision is with source modules, not test files.

runDeployLogReconcileJob() now takes its two filesystem calls as an optional deps
parameter, defaulting to the real ones. The test passes fakes and no longer
touches the module registry, so there is nothing left to collide.

* fix(deploy): redact provider-resolved secrets from stored deploy errors

Deploy run records (schedule_executions.errorMessage, readable by every
authenticated user via GET /api/schedules/executions) were redacted only
against the secret list a caller had at recorder-construction time --
DB-only nonSecretVars/secretVars, captured BEFORE deployStack() calls
resolveProviderEnvVars() and merges in the bound secret provider's
values (Bitwarden/1Password/Keepass/Proton/Vault/Infisical bulk pulls or
inline refs). A provider-resolved secret that surfaced in compose's raw
stderr therefore passed straight through DeployRunRecorder.end()'s
redaction and into errorMessage unredacted.

Add StackOperationResult.resolvedSecrets, populated once in deployStack()
(the single call site after resolveProviderEnvVars, covering both the
local and Hawser paths) from the post-resolution secretVars -- the same
set executeComposeCommand already redacts streamed lines against. Add
RunRecorder.addSecrets()/DeployRunRecorder.addSecrets() to extend the
redaction list after construction, and call it from every stack_deploy
run-record caller (the three createRunRecorder-based API routes and
deployGitStack, the funnel every git-stack deploy trigger -- including
the unattended cron/webhook ones -- goes through) right after
deployStack() returns and before the recorder is closed.

Regression tests assert on the stored value, not just field presence,
and were verified against a temporarily neutered addSecrets() (the
secret leaked verbatim, confirming they fail for the right reason)
before being restored to pass.

* fix(deploy-logs): scope deploy-log directory and size budget per environment

Before this fix, deploy-log-store.ts kept ALL environments' deploy-run log
files in a single shared deploy-logs/ directory and enforced a single
BUDGET_BYTES total across all of them (SizeBudgetTracker). A user with
deploy rights in one environment could fill that shared budget with a noisy
deploy, silently truncating deploy logs and summaries belonging to every
other environment -- a multi-tenant audit-trail integrity violation.

- deploy-log-store.ts: every path/size function now takes an `envId` and
  resolves to deploy-logs/<envDirName(envId)>/ (envDirName(null) => "null").
  SizeBudgetTracker measures "everything else" scoped to its own run's
  environment only. Added envDirName/parseEnvDirName, listEnvDirNames() for
  reconcile-job discovery, and a readRunLog() fallback to the pre-F5 flat
  path (unreleased feature, no production data in that layout -- fallback
  exists purely as a robustness measure).
- deploy-run-record.ts: DeployRunRecorder threads its envId through to
  appendRunLog() and its default SizeBudgetTracker.
- db.ts: getScheduleExecutionIdsByType() now also returns environmentId, so
  the reconcile job can group records per environment.
- deploy-log-reconcile.ts: reconciles PER ENVIRONMENT (union of directories
  on disk and environments referenced by records), so an orphan file in one
  environment can never be deleted -- or a record marked logMissing --
  because of a same-numbered file/record in a different environment.
- Both single-run routes (GET/DELETE .../deploys/[runId], GET .../log) now
  pass run.environmentId through to readRunLog()/deleteRunLog().

Tests: new cross-environment isolation coverage at the SizeBudgetTracker,
DeployRunRecorder, and reconcile-job levels, each proven against a reverted
(shared-directory) mutation to confirm it fails for the right reason before
being reverted back. Full suite: 3011 pass / 0 fail (was 2987 pass / 0 fail).

* fix(deploy): forbid deleting a non-terminal deploy run

DELETE /api/stacks/[name]/deploys/[runId] deleted the schedule_executions
row and the on-disk log file with no check on the run's status, so a
caller could delete their OWN still-running deploy's record. The
background deploy process keeps going after that: recorder.line()
recreates the just-deleted log file via appendRunLog(), recorder.end()'s
updateScheduleExecution() becomes a silent no-op (UPDATE WHERE id=x, 0
rows), and the default-enabled reconcile job then treats the recreated
file as an orphan and purges it -- erasing every trace of a deploy that
actually ran and had real host side effects.

Add isTerminalRunStatus() (deploy-run-access.ts) as an explicit allow-list
of the run-closing statuses (success/failed/skipped/warning/error/
cancelled/stale) and use it to gate the DELETE handler: a non-terminal
status (queued/running, or anything this code doesn't recognize -- the
status column is free text) now returns 409 and touches neither the log
file nor the database row. The check runs after the existing
ownership/permission checks, so an unauthorized or wrong-stack caller
still gets 403/404 rather than learning the run's status via a 409.

Deliberately its own allow-list rather than reusing
deploy-log-reconcile-core.ts's isEligibleForMissingMark(): that helper
fails OPEN for an unrecognized status because marking logMissing is
reversible metadata-only. Deleting a run is not reversible, so this
check fails CLOSED instead.

* test(deploy): guard that deployStack records the secret set it redacts against

A review found F4's single production line -- stacks.ts's
`result.resolvedSecrets = Object.values(secretVars)` -- had zero coverage:
deleting it left the whole targeted suite green. The two tests that reference
`resolvedSecrets` fake deployStack wholesale and hand-set the field, proving the
downstream recorder wiring but never that the real stacks.ts populates it.

A behaviour test of the real deployStack() is infeasible in this suite, established
empirically, not assumed:
  - tests/helpers/stacks-fake.ts mock.module()s $lib/server/stacks at import time,
    freezing that specifier for the whole Bun process; two suites load it, so
    `import { deployStack }` resolves to the fake for the entire run, and Bun has no
    per-file process isolation to escape it.
  - pulling the real $lib/server/stacks into a test's import graph instantiates
    better-sqlite3, which Bun does not support and crashes at load (reproduced).

So this follows the pattern the suite already uses for deployStack-internal
invariants (deploy-stack-online.test.ts, stack-create-start-build-options.test.ts):
a source guard, with comments stripped before matching and a vacuity guard. It pins
the wiring resolveProviderEnvVars -> secretVars -> executeComposeCommand (redaction)
AND -> result.resolvedSecrets (recording). Counter-tested: each assertion goes red
for its own mutation, including the exact deletion the review demonstrated.

* chore(openapi): regenerate spec after rebase onto upstream/main

* test(stacks): fake requireEnvAccess in the compose-endpoint auth stub

The old merge commit into this branch had manually patched this fake to
add requireEnvAccess (compose/+server.ts calls it, and authEnabled is
false here so the real implementation is a no-op) -- that fixup lived
only in the merge commit itself, not in any single commit, so a plain
rebase onto upstream/main dropped it. Restored; full suite is green
(3212 pass, 0 fail).

* docs(openapi): document deployLogReconcileCron/Enabled in the settings body

upstream's new Gate 7 (body-field drift check) caught that this PR's
deployLogReconcileCron/deployLogReconcileEnabled fields were read in
POST /api/settings/general but never listed in its @openapi body
annotation. Documented them and regenerated the spec; all openapi
gates and the full test suite (3221 pass, 0 fail) are green.

* fix(deploy-log-reconcile): protect an in-flight deploy's file from a TOCTOU orphan-delete

The reconcile job snapshots stack_deploy records once via
getScheduleExecutionIdsByType() before walking each environment's files.
A deploy whose record is created after that snapshot has no matching id
yet, but its log file can already exist by the time this environment's
listRunLogIds() runs -- indistinguishable from a genuine orphan by id
alone, and previously deleted on the spot while still being written to.

Add getRunLogMtimeMs() (deploy-log-store.ts) and use it as a second
signal alongside the records snapshot: a delete candidate whose file was
modified within the last 5 minutes is left alone this run instead of
deleted, since appendRunLog() keeps bumping a live deploy's mtime for as
long as it keeps writing. A later reconcile run will see the by-then-
created record and correctly never touch the file again -- nothing is
lost, it is just given one more cycle to prove itself real, the same
trade-off isEligibleForMissingMark() already makes on the other side of
this race.

* fix(git): record a stack_deploy run for the UI's manual deploy button

deployGitStack's doc comment claimed it was the single function every
git-stack deploy trigger funnels through, and wired the stack_deploy run
recorder in there on that basis. deployGitStackWithProgress -- the UI's
"Deploy" button (deploy-stream/+server.ts) -- runs its own clone/read/
deploy pipeline instead of calling deployGitStack (it needs its own
discrete onProgress stages, not deployGitStack's onLine streaming), so
it was the one caller that reached deployStack() without ever going
through createRunRecorder: webhook, cron, and config-update-triggered
deploys were recorded, the manual button was not.

Wire the same recorder setup into deployGitStackWithProgress, right
before its own deployStack() call, mirroring deployGitStack's shape
(composeHash/envHash/secrets from the DB-only vars, addSecrets() for the
provider-resolved values deployStack() only reveals after it returns,
end() closing the row on every path out). onLine mirrors output into the
run's log file without being forwarded to onProgress, so the existing
progress-stage streaming is untouched.

Corrected deployGitStack's doc comment, which is no longer accurate now
that a second call site exists, and extended the existing source-level
guard test (previously asserting exactly one createRunRecorder call in
the whole file) to check both functions individually.
2026-09-08 07:12:47 +05:30

5933 lines
211 KiB
TypeScript

/**
* Database Operations Module
*
* Provides all database operations using Drizzle ORM.
* Supports both SQLite and PostgreSQL.
*/
import type { SecretProviderConfig, SecretProviderType } from './secretproviders/shared';
import { mergeProviderConfigForWrite } from './secretproviders/shared';
import {
db,
isPostgres,
isSqlite,
eq,
and,
or,
desc,
asc,
like,
sql,
inArray,
isNull,
isNotNull,
// Schema tables
environments,
registries,
stackEvents,
settings,
configSets,
hostMetrics,
autoUpdateSettings,
notificationSettings,
environmentNotifications,
authSettings,
users,
sessions,
roles,
userRoles,
ldapConfig,
oidcConfig,
gitCredentials,
gitRepositories,
gitStacks,
secretProviders,
stackSources,
containerIconOverrides,
vulnerabilityScans,
auditLogs,
containerEvents,
userPreferences,
scheduleExecutions,
stackEnvironmentVariables,
pendingContainerUpdates,
backupDestinations,
backupConfigs,
// Types
type Environment,
type Registry,
type StackEvent,
type Setting,
type ConfigSet,
type HostMetric,
type AutoUpdateSetting,
type NotificationSetting,
type EnvironmentNotification,
type AuthSetting,
type User,
type Session,
type Role,
type UserRole,
type LdapConfig,
type OidcConfig,
type GitCredential,
type GitRepository,
type GitStack,
type SecretProviderRow,
type StackSource,
type VulnerabilityScan,
type AuditLog,
type ContainerEvent,
type ScheduleExecution,
type StackEnvironmentVariable,
type PendingContainerUpdate,
type BackupDestination,
type BackupConfig
} from './db/drizzle.js';
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
import { encrypt, decrypt, decryptStrict, isEncrypted } from './encryption.js';
import { parseEnvInterpolation } from './env-interpolation';
import { parseInjectedSecretKeys, serializeInjectedSecretKeys } from './stack-secret-keys';
import { invalidateVulnerabilitiesCache } from './vulnerabilities-cache';
// Re-export for backwards compatibility
export { db, isPostgres, isSqlite };
export type {
Environment,
Registry,
ConfigSet,
HostMetric,
AutoUpdateSetting as AutoUpdateSettingType,
User,
Session,
Role,
UserRole,
LdapConfig,
OidcConfig,
GitCredential,
GitRepository,
GitStack,
SecretProviderRow,
StackSource,
VulnerabilityScan,
AuditLog,
ContainerEvent
};
// Initialize database (no-op now, kept for API compatibility)
export function initDatabase() {
// Database is already initialized by drizzle.ts
}
// =============================================================================
// ENVIRONMENT OPERATIONS
// =============================================================================
export async function getEnvironments(): Promise<Environment[]> {
const results = await db.select().from(environments).orderBy(sql`lower(${environments.name})`);
return results.map((e: Environment) => ({
...e,
tlsKey: decrypt(e.tlsKey),
hawserToken: decrypt(e.hawserToken)
}));
}
export async function hasEnvironments(): Promise<boolean> {
const results = await db.select({ id: environments.id }).from(environments).limit(1);
return results.length > 0;
}
export async function getEnvironment(id: number): Promise<Environment | undefined> {
const results = await db.select().from(environments).where(eq(environments.id, id));
if (!results[0]) return undefined;
return {
...results[0],
tlsKey: decrypt(results[0].tlsKey),
hawserToken: decrypt(results[0].hawserToken)
};
}
export async function getEnvironmentByName(name: string): Promise<Environment | undefined> {
const results = await db.select().from(environments).where(eq(environments.name, name));
if (!results[0]) return undefined;
return {
...results[0],
tlsKey: decrypt(results[0].tlsKey),
hawserToken: decrypt(results[0].hawserToken)
};
}
export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Environment> {
const result = await db.insert(environments).values({
name: env.name,
host: env.host || null,
port: env.port || 2375,
protocol: env.protocol || 'http',
tlsCa: env.tlsCa || null,
tlsCert: env.tlsCert || null,
tlsKey: encrypt(env.tlsKey) || null,
tlsSkipVerify: env.tlsSkipVerify ?? false,
icon: env.icon || 'globe',
socketPath: env.socketPath || '/var/run/docker.sock',
collectActivity: env.collectActivity !== false,
collectMetrics: env.collectMetrics !== false,
highlightChanges: env.highlightChanges !== false,
labels: env.labels || null,
connectionType: env.connectionType || 'socket',
hawserToken: encrypt(env.hawserToken) || null
}).returning();
return {
...result[0],
tlsKey: decrypt(result[0].tlsKey),
hawserToken: decrypt(result[0].hawserToken)
};
}
export async function updateEnvironment(id: number, env: Partial<Environment>): Promise<Environment | undefined> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (env.name !== undefined) updateData.name = env.name;
if (env.host !== undefined) updateData.host = env.host;
if (env.port !== undefined) updateData.port = env.port;
if (env.protocol !== undefined) updateData.protocol = env.protocol;
if (env.tlsCa !== undefined) updateData.tlsCa = env.tlsCa;
if (env.tlsCert !== undefined) updateData.tlsCert = env.tlsCert;
if (env.tlsKey !== undefined) updateData.tlsKey = encrypt(env.tlsKey);
if (env.tlsSkipVerify !== undefined) updateData.tlsSkipVerify = env.tlsSkipVerify;
if (env.icon !== undefined) updateData.icon = env.icon;
if (env.socketPath !== undefined) updateData.socketPath = env.socketPath;
if (env.collectActivity !== undefined) updateData.collectActivity = env.collectActivity;
if (env.collectMetrics !== undefined) updateData.collectMetrics = env.collectMetrics;
if (env.highlightChanges !== undefined) updateData.highlightChanges = env.highlightChanges;
if (env.labels !== undefined) updateData.labels = env.labels;
if (env.connectionType !== undefined) updateData.connectionType = env.connectionType;
if (env.hawserToken !== undefined) updateData.hawserToken = encrypt(env.hawserToken);
await db.update(environments).set(updateData).where(eq(environments.id, id));
return getEnvironment(id);
}
export async function deleteEnvironment(id: number): Promise<boolean> {
const env = await getEnvironment(id);
if (!env) return false;
// Clean up in-memory metrics
const { clearEnvironmentMetrics } = await import('./metrics-store.js');
clearEnvironmentMetrics(id);
// Clean up related records that don't have cascade delete defined
try {
await db.delete(hostMetrics).where(eq(hostMetrics.environmentId, id));
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[DB] Failed to cleanup host metrics for environment:', errorMsg);
}
try {
await db.delete(stackEvents).where(eq(stackEvents.environmentId, id));
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[DB] Failed to cleanup stack events for environment:', errorMsg);
}
try {
await db.delete(autoUpdateSettings).where(eq(autoUpdateSettings.environmentId, id));
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[DB] Failed to cleanup auto-update schedules for environment:', errorMsg);
}
await db.delete(environments).where(eq(environments.id, id));
return true;
}
// =============================================================================
// REGISTRY OPERATIONS
// =============================================================================
export async function getRegistries(): Promise<Registry[]> {
const results = await db.select().from(registries).orderBy(desc(registries.isDefault), asc(registries.name));
return results.map((r: Registry) => ({ ...r, password: decrypt(r.password) }));
}
export async function getRegistry(id: number): Promise<Registry | undefined> {
const results = await db.select().from(registries).where(eq(registries.id, id));
if (!results[0]) return undefined;
return { ...results[0], password: decrypt(results[0].password) };
}
export async function getDefaultRegistry(): Promise<Registry | undefined> {
const results = await db.select().from(registries).where(eq(registries.isDefault, true));
if (!results[0]) return undefined;
return { ...results[0], password: decrypt(results[0].password) };
}
export async function createRegistry(registry: Omit<Registry, 'id' | 'createdAt' | 'updatedAt'>): Promise<Registry> {
const result = await db.insert(registries).values({
name: registry.name,
url: registry.url,
username: registry.username || null,
password: encrypt(registry.password) || null,
isDefault: registry.isDefault || false
}).returning();
return {
...result[0],
password: decrypt(result[0].password)
};
}
export async function updateRegistry(id: number, registry: Partial<Registry>): Promise<Registry | undefined> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (registry.name !== undefined) updateData.name = registry.name;
if (registry.url !== undefined) updateData.url = registry.url;
if (registry.username !== undefined) updateData.username = registry.username || null;
if (registry.password !== undefined) updateData.password = encrypt(registry.password) || null;
if (registry.isDefault !== undefined) updateData.isDefault = registry.isDefault;
await db.update(registries).set(updateData).where(eq(registries.id, id));
return getRegistry(id);
}
export async function deleteRegistry(id: number): Promise<boolean> {
const registry = await getRegistry(id);
if (!registry) return false;
await db.delete(registries).where(eq(registries.id, id));
return true;
}
export async function setDefaultRegistry(id: number): Promise<boolean> {
await db.update(registries).set({ isDefault: false });
await db.update(registries).set({ isDefault: true }).where(eq(registries.id, id));
return true;
}
// =============================================================================
// SECRET PROVIDER OPERATIONS
// =============================================================================
// Pluggable secret providers (1Password, Infisical, HashiCorp Vault, ...). The
// per-provider `config` object is stored as an encrypted JSON blob; the `token`
// or `host`/`token` fields live inside it. See src/lib/server/secretproviders.
/** Row without the (secret) config, safe to return to the UI / list views. */
export interface SecretProviderSummary {
id: number;
type: string;
name: string;
createdAt: string | null;
updatedAt: string | null;
}
/** Row with its config decrypted and parsed. Server-side use only. */
export interface SecretProviderWithConfig extends SecretProviderSummary {
config: SecretProviderConfig;
}
export async function getSecretProviders(): Promise<SecretProviderSummary[]> {
const results = await db.select({
id: secretProviders.id,
type: secretProviders.type,
name: secretProviders.name,
createdAt: secretProviders.createdAt,
updatedAt: secretProviders.updatedAt
}).from(secretProviders).orderBy(asc(secretProviders.name));
return results;
}
export async function getSecretProviderById(id: number): Promise<SecretProviderWithConfig | undefined> {
const results = await db.select().from(secretProviders).where(eq(secretProviders.id, id));
if (!results[0]) return undefined;
const row = results[0];
const decrypted = decrypt(row.config);
const config = decrypted
? (JSON.parse(decrypted) as SecretProviderConfig)
: ({} as SecretProviderConfig);
return {
id: row.id,
type: row.type,
name: row.name,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
config
};
}
/** Lightweight existence check (no config decrypt), for validating a bound provider id. */
export async function secretProviderExists(id: number): Promise<boolean> {
const rows = await db.select({ id: secretProviders.id }).from(secretProviders).where(eq(secretProviders.id, id));
return rows.length > 0;
}
/** Stacks currently bound to a secret provider, so a delete can warn which ones lose it. */
export async function getStacksUsingSecretProvider(id: number): Promise<Array<{ stackName: string; environmentId: number | null }>> {
return db
.select({ stackName: stackSources.stackName, environmentId: stackSources.environmentId })
.from(stackSources)
.where(eq(stackSources.secretProviderId, id));
}
export async function createSecretProvider(data: {
type: SecretProviderType;
name: string;
config: SecretProviderConfig;
}): Promise<SecretProviderSummary> {
const encrypted = encrypt(JSON.stringify(data.config));
if (!encrypted) throw new Error('Config is required');
const result = await db.insert(secretProviders).values({
type: data.type,
name: data.name,
config: encrypted
}).returning();
const { config: _omit, ...safe } = result[0];
return safe;
}
export async function updateSecretProvider(
id: number,
data: { name?: string; type?: SecretProviderType; config?: SecretProviderConfig }
): Promise<SecretProviderSummary | undefined> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.name !== undefined) updateData.name = data.name;
if (data.type !== undefined) updateData.type = data.type;
if (data.config !== undefined) {
// The edit form pre-fills non-secret fields but leaves the token blank to mean
// "keep the stored secret". Merge the incoming config OVER the existing one so a
// blank/absent secret keeps its stored value instead of being wiped. The edit
// form always sends `type` (its dropdown is read-only), so gate on the type not
// CHANGING rather than on it being absent - a genuine type change is a full
// re-config and skips the merge. (#1432)
let merged: Record<string, unknown> = { ...(data.config as Record<string, unknown>) };
const existing = await getSecretProviderById(id);
const typeUnchanged = data.type === undefined || (existing !== undefined && data.type === existing.type);
if (typeUnchanged && existing) {
merged = mergeProviderConfigForWrite(
data.config as Record<string, unknown>,
existing.config as Record<string, unknown>
);
}
const encrypted = encrypt(JSON.stringify(merged));
if (encrypted) updateData.config = encrypted;
}
await db.update(secretProviders).set(updateData).where(eq(secretProviders.id, id));
const results = await db.select({
id: secretProviders.id,
type: secretProviders.type,
name: secretProviders.name,
createdAt: secretProviders.createdAt,
updatedAt: secretProviders.updatedAt
}).from(secretProviders).where(eq(secretProviders.id, id));
return results[0];
}
export async function deleteSecretProvider(id: number): Promise<SecretProviderSummary | null> {
const rows = await db.delete(secretProviders)
.where(eq(secretProviders.id, id))
.returning({
id: secretProviders.id,
type: secretProviders.type,
name: secretProviders.name,
createdAt: secretProviders.createdAt,
updatedAt: secretProviders.updatedAt
});
return rows[0] ?? null;
}
// =============================================================================
// STACK EVENT LOGGING
// =============================================================================
export async function logStackEvent(stackName: string, eventType: string, metadata?: any, environmentId?: number) {
await db.insert(stackEvents).values({
environmentId: environmentId || null,
stackName,
eventType,
metadata: metadata ? JSON.stringify(metadata) : null
});
}
export async function getStackEvents(limit = 50, environmentId?: number): Promise<StackEvent[]> {
if (environmentId) {
return db.select().from(stackEvents)
.where(eq(stackEvents.environmentId, environmentId))
.orderBy(desc(stackEvents.timestamp))
.limit(limit);
}
return db.select().from(stackEvents)
.orderBy(desc(stackEvents.timestamp))
.limit(limit);
}
// =============================================================================
// SETTINGS MANAGEMENT
// =============================================================================
export async function getSetting(key: string): Promise<any> {
const results = await db.select().from(settings).where(eq(settings.key, key));
if (!results[0]) return null;
try {
return JSON.parse(results[0].value);
} catch {
return results[0].value;
}
}
export async function setSetting(key: string, value: any): Promise<void> {
const jsonValue = JSON.stringify(value);
await db.insert(settings).values({
key,
value: jsonValue
}).onConflictDoUpdate({
target: settings.key,
set: { value: jsonValue, updatedAt: new Date().toISOString() }
});
}
export async function deleteSetting(key: string): Promise<void> {
await db.delete(settings).where(eq(settings.key, key));
}
/**
* Return every setting whose key starts with `prefix`, JSON-parsed. Used by the
* backup operation journal (one durable row per in-flight op, keyed
* `backup:op:<uuid>`) so concurrent ops write DISTINCT rows and cannot
* lost-update a shared array — see src/lib/server/backups/journal.ts.
*/
export async function listSettingsByPrefix(prefix: string): Promise<Array<{ key: string; value: any }>> {
// Escape LIKE metacharacters in the prefix so `%`/`_` in a key are literal.
const escaped = prefix.replace(/[\\%_]/g, (c) => '\\' + c);
const rows = await db.select().from(settings)
.where(sql`${settings.key} LIKE ${escaped + '%'} ESCAPE '\\'`);
return rows.map((r: { key: string; value: string }) => {
let value: any;
try { value = JSON.parse(r.value); } catch { value = r.value; }
return { key: r.key, value };
});
}
export async function getEnvSetting(key: string, envId?: number): Promise<any> {
if (envId !== undefined) {
const envKey = `env_${envId}_${key}`;
const results = await db.select().from(settings).where(eq(settings.key, envKey));
if (results[0]) {
try {
return JSON.parse(results[0].value);
} catch {
return results[0].value;
}
}
}
return getSetting(key);
}
export async function setEnvSetting(key: string, value: any, envId?: number): Promise<void> {
const actualKey = envId !== undefined ? `env_${envId}_${key}` : key;
await setSetting(actualKey, value);
}
// =============================================================================
// USER SETTINGS (for per-user preferences like themes)
// =============================================================================
export async function getUserSetting(userId: number, key: string): Promise<any> {
const userKey = `user:${userId}:${key}`;
return getSetting(userKey);
}
export async function setUserSetting(userId: number, key: string, value: any): Promise<void> {
const userKey = `user:${userId}:${key}`;
await setSetting(userKey, value);
}
export async function getUserThemePreferences(userId: number): Promise<{
lightTheme: string;
darkTheme: string;
font: string;
fontSize: string;
gridFontSize: string;
terminalFont: string;
editorFont: string;
animateIcons: boolean;
coloredActionButtons: boolean;
actionIconSize: string;
editorIndentGuides: boolean;
}> {
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons, coloredActionButtons, actionIconSize, editorIndentGuides] = await Promise.all([
getUserSetting(userId, 'light_theme'),
getUserSetting(userId, 'dark_theme'),
getUserSetting(userId, 'font'),
getUserSetting(userId, 'font_size'),
getUserSetting(userId, 'grid_font_size'),
getUserSetting(userId, 'terminal_font'),
getUserSetting(userId, 'editor_font'),
getUserSetting(userId, 'animate_icons'),
getUserSetting(userId, 'colored_action_buttons'),
getUserSetting(userId, 'action_icon_size'),
getUserSetting(userId, 'editor_indent_guides')
]);
return {
lightTheme: lightTheme || 'default',
darkTheme: darkTheme || 'default',
font: font || 'system',
fontSize: fontSize || 'normal',
gridFontSize: gridFontSize || 'normal',
terminalFont: terminalFont || 'system-mono',
editorFont: editorFont || 'system-mono',
// Default ON — only false when explicitly stored
animateIcons: animateIcons === 'false' ? false : true,
// Default OFF — only true when explicitly stored
coloredActionButtons: coloredActionButtons === 'true',
actionIconSize: actionIconSize || 'normal',
// Default OFF — only true when explicitly stored (#1410)
editorIndentGuides: editorIndentGuides === 'true'
};
}
export async function setUserThemePreferences(
userId: number,
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string; editorIndentGuides?: boolean }
): Promise<void> {
const updates: Promise<void>[] = [];
if (prefs.lightTheme !== undefined) {
updates.push(setUserSetting(userId, 'light_theme', prefs.lightTheme));
}
if (prefs.darkTheme !== undefined) {
updates.push(setUserSetting(userId, 'dark_theme', prefs.darkTheme));
}
if (prefs.font !== undefined) {
updates.push(setUserSetting(userId, 'font', prefs.font));
}
if (prefs.fontSize !== undefined) {
updates.push(setUserSetting(userId, 'font_size', prefs.fontSize));
}
if (prefs.gridFontSize !== undefined) {
updates.push(setUserSetting(userId, 'grid_font_size', prefs.gridFontSize));
}
if (prefs.terminalFont !== undefined) {
updates.push(setUserSetting(userId, 'terminal_font', prefs.terminalFont));
}
if (prefs.editorFont !== undefined) {
updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont));
}
if (prefs.animateIcons !== undefined) {
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
}
if (prefs.coloredActionButtons !== undefined) {
updates.push(setUserSetting(userId, 'colored_action_buttons', prefs.coloredActionButtons ? 'true' : 'false'));
}
if (prefs.actionIconSize !== undefined) {
updates.push(setUserSetting(userId, 'action_icon_size', prefs.actionIconSize));
}
if (prefs.editorIndentGuides !== undefined) {
updates.push(setUserSetting(userId, 'editor_indent_guides', prefs.editorIndentGuides ? 'true' : 'false'));
}
await Promise.all(updates);
}
// =============================================================================
// GRID COLUMN PREFERENCES
// =============================================================================
export async function getGridPreferences(userId?: number): Promise<AllGridPreferences> {
const key = userId ? `user:${userId}:grid_preferences` : 'grid_preferences';
const value = await getSetting(key);
return value || {};
}
export async function setGridPreferences(
gridId: GridId,
prefs: GridColumnPreferences,
userId?: number
): Promise<void> {
const key = userId ? `user:${userId}:grid_preferences` : 'grid_preferences';
const current = await getGridPreferences(userId);
current[gridId] = prefs;
await setSetting(key, current);
}
export async function deleteGridPreferences(gridId: GridId, userId?: number): Promise<void> {
const key = userId ? `user:${userId}:grid_preferences` : 'grid_preferences';
const current = await getGridPreferences(userId);
delete current[gridId];
await setSetting(key, current);
}
export async function resetAllGridPreferences(userId?: number): Promise<void> {
const key = userId ? `user:${userId}:grid_preferences` : 'grid_preferences';
await deleteSetting(key);
}
// =============================================================================
// SIDEBAR MENU PREFERENCES
// =============================================================================
export interface SidebarPreferences {
order: string[];
hidden: string[];
}
export async function getSidebarPreferences(userId?: number): Promise<SidebarPreferences> {
const key = userId ? `user:${userId}:sidebar_preferences` : 'sidebar_preferences';
const value = await getSetting(key);
return value || { order: [], hidden: [] };
}
export async function setSidebarPreferences(prefs: SidebarPreferences, userId?: number): Promise<void> {
const key = userId ? `user:${userId}:sidebar_preferences` : 'sidebar_preferences';
await setSetting(key, prefs);
}
export async function deleteSidebarPreferences(userId?: number): Promise<void> {
const key = userId ? `user:${userId}:sidebar_preferences` : 'sidebar_preferences';
await deleteSetting(key);
}
// =============================================================================
// ENVIRONMENT PUBLIC IPS (for port links)
// =============================================================================
export async function getEnvironmentPublicIps(): Promise<Record<string, string>> {
const value = await getSetting('environment_public_ips');
return value || {};
}
export async function setEnvironmentPublicIp(envId: number, publicIp: string | null): Promise<void> {
const current = await getEnvironmentPublicIps();
if (publicIp) {
current[envId.toString()] = publicIp;
} else {
delete current[envId.toString()];
}
await setSetting('environment_public_ips', current);
}
export async function deleteEnvironmentPublicIp(envId: number): Promise<void> {
await setEnvironmentPublicIp(envId, null);
}
// =============================================================================
// CONFIG SET OPERATIONS
// =============================================================================
export interface ConfigSetData {
id: number;
name: string;
description?: string | null;
envVars?: { key: string; value: string }[];
labels?: { key: string; value: string }[];
ports?: { hostPort: string; containerPort: string; protocol: string }[];
volumes?: { hostPath: string; containerPath: string; mode: string }[];
networkMode: string;
restartPolicy: string;
createdAt: string;
updatedAt: string;
}
export async function getConfigSets(): Promise<ConfigSetData[]> {
const rows = await db.select().from(configSets).orderBy(asc(configSets.name));
return rows.map((row: typeof configSets.$inferSelect) => ({
...row,
envVars: row.envVars ? JSON.parse(row.envVars) : [],
labels: row.labels ? JSON.parse(row.labels) : [],
ports: row.ports ? JSON.parse(row.ports) : [],
volumes: row.volumes ? JSON.parse(row.volumes) : []
}));
}
export async function getConfigSet(id: number): Promise<ConfigSetData | undefined> {
const results = await db.select().from(configSets).where(eq(configSets.id, id));
if (!results[0]) return undefined;
const row = results[0];
return {
...row,
envVars: row.envVars ? JSON.parse(row.envVars) : [],
labels: row.labels ? JSON.parse(row.labels) : [],
ports: row.ports ? JSON.parse(row.ports) : [],
volumes: row.volumes ? JSON.parse(row.volumes) : []
};
}
export async function createConfigSet(configSet: Omit<ConfigSetData, 'id' | 'createdAt' | 'updatedAt'>): Promise<ConfigSetData> {
const result = await db.insert(configSets).values({
name: configSet.name,
description: configSet.description || null,
envVars: configSet.envVars ? JSON.stringify(configSet.envVars) : null,
labels: configSet.labels ? JSON.stringify(configSet.labels) : null,
ports: configSet.ports ? JSON.stringify(configSet.ports) : null,
volumes: configSet.volumes ? JSON.stringify(configSet.volumes) : null,
networkMode: configSet.networkMode || 'bridge',
restartPolicy: configSet.restartPolicy || 'no'
}).returning();
return getConfigSet(result[0].id) as Promise<ConfigSetData>;
}
export async function updateConfigSet(id: number, configSet: Partial<ConfigSetData>): Promise<ConfigSetData | undefined> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (configSet.name !== undefined) updateData.name = configSet.name;
if (configSet.description !== undefined) updateData.description = configSet.description || null;
if (configSet.envVars !== undefined) updateData.envVars = JSON.stringify(configSet.envVars);
if (configSet.labels !== undefined) updateData.labels = JSON.stringify(configSet.labels);
if (configSet.ports !== undefined) updateData.ports = JSON.stringify(configSet.ports);
if (configSet.volumes !== undefined) updateData.volumes = JSON.stringify(configSet.volumes);
if (configSet.networkMode !== undefined) updateData.networkMode = configSet.networkMode;
if (configSet.restartPolicy !== undefined) updateData.restartPolicy = configSet.restartPolicy;
await db.update(configSets).set(updateData).where(eq(configSets.id, id));
return getConfigSet(id);
}
export async function deleteConfigSet(id: number): Promise<boolean> {
await db.delete(configSets).where(eq(configSets.id, id));
return true;
}
// =============================================================================
// HOST METRICS OPERATIONS
// =============================================================================
export async function saveHostMetric(
cpuPercent: number,
memoryPercent: number,
memoryUsed: number,
memoryTotal: number,
environmentId?: number,
_skipEnvCheck = false
): Promise<void> {
// Delegated to in-memory ring buffer (no DB writes)
if (!environmentId) return;
const { pushMetric } = await import('./metrics-store.js');
pushMetric(environmentId, cpuPercent, memoryPercent, memoryUsed, memoryTotal);
}
export async function getHostMetrics(limit = 60, environmentId?: number): Promise<HostMetric[]> {
if (environmentId) {
const { getMetricsHistory } = await import('./metrics-store.js');
// getMetricsHistory returns oldest-first, but callers expect newest-first
return getMetricsHistory(environmentId, limit).reverse();
}
const { getAllMetrics } = await import('./metrics-store.js');
return getAllMetrics(limit);
}
export async function getLatestHostMetrics(environmentId: number): Promise<HostMetric | null> {
const { getLatestMetric } = await import('./metrics-store.js');
return getLatestMetric(environmentId);
}
// =============================================================================
// AUTO-UPDATE SETTINGS
// =============================================================================
export type VulnerabilityCriteria = 'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current';
export interface AutoUpdateSettingData {
id: number;
environmentId: number | null;
containerName: string;
enabled: boolean;
scheduleType: 'daily' | 'weekly' | 'custom';
cronExpression: string | null;
vulnerabilityCriteria: VulnerabilityCriteria | null;
lastChecked: string | null;
lastUpdated: string | null;
createdAt: string;
updatedAt: string;
}
export async function getAutoUpdateSettings(environmentId?: number): Promise<AutoUpdateSettingData[]> {
if (environmentId) {
return db.select().from(autoUpdateSettings)
.where(eq(autoUpdateSettings.environmentId, environmentId)) as Promise<AutoUpdateSettingData[]>;
}
return db.select().from(autoUpdateSettings) as Promise<AutoUpdateSettingData[]>;
}
export async function getAutoUpdateSetting(containerName: string, environmentId?: number): Promise<AutoUpdateSettingData | undefined> {
const results = await db.select().from(autoUpdateSettings)
.where(and(
eq(autoUpdateSettings.containerName, containerName),
environmentId ? eq(autoUpdateSettings.environmentId, environmentId) : isNull(autoUpdateSettings.environmentId)
));
return results[0] as AutoUpdateSettingData | undefined;
}
export async function getAutoUpdateSettingById(id: number): Promise<AutoUpdateSettingData | undefined> {
const results = await db.select().from(autoUpdateSettings)
.where(eq(autoUpdateSettings.id, id));
return results[0] as AutoUpdateSettingData | undefined;
}
export async function updateAutoUpdateSettingById(id: number, data: Partial<AutoUpdateSettingData>): Promise<void> {
await db.update(autoUpdateSettings)
.set({
...data,
updatedAt: new Date().toISOString()
})
.where(eq(autoUpdateSettings.id, id));
}
export async function getEnabledAutoUpdateSettings(): Promise<AutoUpdateSettingData[]> {
return db.select().from(autoUpdateSettings)
.where(eq(autoUpdateSettings.enabled, true)) as Promise<AutoUpdateSettingData[]>;
}
export async function getAllAutoUpdateSettings(): Promise<AutoUpdateSettingData[]> {
return db.select().from(autoUpdateSettings)
.orderBy(desc(autoUpdateSettings.containerName)) as Promise<AutoUpdateSettingData[]>;
}
export async function upsertAutoUpdateSetting(
containerName: string,
settingsData: {
enabled: boolean;
scheduleType: 'daily' | 'weekly' | 'custom';
cronExpression?: string | null;
vulnerabilityCriteria?: VulnerabilityCriteria | null;
},
environmentId?: number
): Promise<AutoUpdateSettingData> {
const existing = await getAutoUpdateSetting(containerName, environmentId);
if (existing) {
await db.update(autoUpdateSettings)
.set({
enabled: settingsData.enabled,
scheduleType: settingsData.scheduleType,
cronExpression: settingsData.cronExpression || null,
vulnerabilityCriteria: settingsData.vulnerabilityCriteria || 'never',
updatedAt: new Date().toISOString()
})
.where(eq(autoUpdateSettings.id, existing.id));
return getAutoUpdateSetting(containerName, environmentId) as Promise<AutoUpdateSettingData>;
} else {
await db.insert(autoUpdateSettings).values({
environmentId: environmentId || null,
containerName,
enabled: settingsData.enabled,
scheduleType: settingsData.scheduleType,
cronExpression: settingsData.cronExpression || null,
vulnerabilityCriteria: settingsData.vulnerabilityCriteria || 'never'
});
return getAutoUpdateSetting(containerName, environmentId) as Promise<AutoUpdateSettingData>;
}
}
export async function updateAutoUpdateLastChecked(containerName: string, environmentId?: number): Promise<void> {
await db.update(autoUpdateSettings)
.set({
lastChecked: new Date().toISOString(),
updatedAt: new Date().toISOString()
})
.where(and(
eq(autoUpdateSettings.containerName, containerName),
environmentId ? eq(autoUpdateSettings.environmentId, environmentId) : isNull(autoUpdateSettings.environmentId)
));
}
export async function updateAutoUpdateLastUpdated(containerName: string, environmentId?: number): Promise<void> {
await db.update(autoUpdateSettings)
.set({
lastUpdated: new Date().toISOString(),
updatedAt: new Date().toISOString()
})
.where(and(
eq(autoUpdateSettings.containerName, containerName),
environmentId ? eq(autoUpdateSettings.environmentId, environmentId) : isNull(autoUpdateSettings.environmentId)
));
}
export async function deleteAutoUpdateSetting(containerName: string, environmentId?: number): Promise<boolean> {
await db.delete(autoUpdateSettings)
.where(and(
eq(autoUpdateSettings.containerName, containerName),
environmentId ? eq(autoUpdateSettings.environmentId, environmentId) : isNull(autoUpdateSettings.environmentId)
));
return true;
}
// Alias for consistency with plan
export const deleteAutoUpdateSchedule = deleteAutoUpdateSetting;
export async function renameAutoUpdateSchedule(
oldName: string,
newName: string,
environmentId?: number
): Promise<boolean> {
await db.update(autoUpdateSettings)
.set({ containerName: newName })
.where(and(
eq(autoUpdateSettings.containerName, oldName),
environmentId
? eq(autoUpdateSettings.environmentId, environmentId)
: isNull(autoUpdateSettings.environmentId)
));
return true;
}
// =============================================================================
// NOTIFICATION SETTINGS
// =============================================================================
// Event scope: 'environment' = configurable per-environment, 'system' = global only (configured at channel level)
export const NOTIFICATION_EVENT_TYPES = [
// Container lifecycle events (environment-scoped)
{ id: 'container_started', label: 'Container started', description: 'When a container starts running', group: 'container', scope: 'environment' },
{ id: 'container_stopped', label: 'Container stopped', description: 'When a container is stopped', group: 'container', scope: 'environment' },
{ id: 'container_restarted', label: 'Container restarted', description: 'When a container restarts (manual or automatic)', group: 'container', scope: 'environment' },
{ id: 'container_exited', label: 'Container exited', description: 'When a container exits unexpectedly', group: 'container', scope: 'environment' },
{ id: 'container_unhealthy', label: 'Container unhealthy', description: 'When a container health check fails', group: 'container', scope: 'environment' },
{ id: 'container_healthy', label: 'Container healthy', description: 'When a container health check recovers', group: 'container', scope: 'environment' },
{ id: 'container_oom', label: 'Out of memory', description: 'When a container is killed due to out of memory', group: 'container', scope: 'environment' },
{ id: 'container_updated', label: 'Container updated', description: 'When a container image is updated', group: 'container', scope: 'environment' },
{ id: 'image_pulled', label: 'Image pulled', description: 'When a new image is pulled', group: 'container', scope: 'environment' },
// Auto-update events (environment-scoped)
{ id: 'auto_update_success', label: 'Auto-update success', description: 'Container successfully updated to new image', group: 'auto_update', scope: 'environment' },
{ id: 'auto_update_failed', label: 'Auto-update failed', description: 'Container auto-update failed (pull error, start error)', group: 'auto_update', scope: 'environment' },
{ id: 'auto_update_blocked', label: 'Auto-update blocked', description: 'Update blocked due to vulnerability criteria', group: 'auto_update', scope: 'environment' },
{ id: 'updates_detected', label: 'Updates detected', description: 'Container image updates are available (scheduled check)', group: 'auto_update', scope: 'environment' },
{ id: 'newer_version_available', label: 'Newer version tag', description: 'A newer version tag is published for a pinned image (semver, advisory)', group: 'auto_update', scope: 'environment' },
{ id: 'batch_update_success', label: 'Batch update completed', description: 'Scheduled container updates completed successfully', group: 'auto_update', scope: 'environment' },
// Git stack events (environment-scoped)
{ id: 'git_sync_success', label: 'Git sync success', description: 'Git stack synced and deployed successfully', group: 'git_stack', scope: 'environment' },
{ id: 'git_sync_failed', label: 'Git sync failed', description: 'Git stack sync or deploy failed', group: 'git_stack', scope: 'environment' },
{ id: 'git_sync_skipped', label: 'Git sync skipped', description: 'Git stack sync skipped (no changes)', group: 'git_stack', scope: 'environment' },
// Stack events (environment-scoped)
{ id: 'stack_started', label: 'Stack started', description: 'When a compose stack starts', group: 'stack', scope: 'environment' },
{ id: 'stack_stopped', label: 'Stack stopped', description: 'When a compose stack stops', group: 'stack', scope: 'environment' },
{ id: 'stack_deployed', label: 'Stack deployed', description: 'Stack deployed (new or update)', group: 'stack', scope: 'environment' },
{ id: 'stack_deploy_failed', label: 'Stack deploy failed', description: 'Stack deployment failed', group: 'stack', scope: 'environment' },
// Security events (environment-scoped)
{ id: 'vulnerability_critical', label: 'Critical vulnerabilities', description: 'Critical vulnerabilities found in image scan', group: 'security', scope: 'environment' },
{ id: 'vulnerability_high', label: 'High vulnerabilities', description: 'High severity vulnerabilities found in image scan', group: 'security', scope: 'environment' },
{ id: 'vulnerability_any', label: 'Any vulnerabilities', description: 'Any vulnerabilities found in image scan (medium/low)', group: 'security', scope: 'environment' },
// Backup events (environment-scoped)
{ id: 'backup_success', label: 'Backup success', description: 'Backup completed successfully', group: 'backup', scope: 'environment' },
{ id: 'backup_failed', label: 'Backup failed', description: 'Backup failed', group: 'backup', scope: 'environment' },
{ id: 'restore_success', label: 'Restore success', description: 'Restore completed successfully', group: 'backup', scope: 'environment' },
{ id: 'restore_failed', label: 'Restore failed', description: 'Restore failed', group: 'backup', scope: 'environment' },
// System events (global - configured at channel level, not per-environment)
{ id: 'environment_offline', label: 'Environment offline', description: 'Environment became unreachable', group: 'system', scope: 'environment' },
{ id: 'environment_online', label: 'Environment online', description: 'Environment came back online', group: 'system', scope: 'environment' },
{ id: 'disk_space_warning', label: 'Disk space warning', description: 'Docker disk usage exceeds threshold', group: 'system', scope: 'environment' },
{ id: 'image_prune_success', label: 'Image prune success', description: 'Scheduled image prune completed successfully', group: 'system', scope: 'environment' },
{ id: 'image_prune_failed', label: 'Image prune failed', description: 'Scheduled image prune failed', group: 'system', scope: 'environment' },
{ id: 'repo_prune_success', label: 'Backup repository prune success', description: 'Scheduled repository prune completed successfully', group: 'system', scope: 'system' },
{ id: 'repo_prune_failed', label: 'Backup repository prune failed', description: 'Scheduled repository prune failed', group: 'system', scope: 'system' },
{ id: 'repo_check_success', label: 'Backup repository check success', description: 'Scheduled integrity check completed successfully', group: 'system', scope: 'system' },
{ id: 'repo_check_failed', label: 'Backup repository check failed', description: 'Scheduled integrity check found errors or failed', group: 'system', scope: 'system' },
{ id: 'repo_verify_success', label: 'Backup repository data verification success', description: 'Scheduled data verification completed successfully', group: 'system', scope: 'system' },
{ id: 'repo_verify_failed', label: 'Backup repository data verification failed', description: 'Scheduled data verification found corruption or failed', group: 'system', scope: 'system' },
{ id: 'license_expiring', label: 'License expiring', description: 'Enterprise license expiring soon (global)', group: 'system', scope: 'system' }
] as const;
export const NOTIFICATION_EVENT_GROUPS = [
{ id: 'container', label: 'Container events' },
{ id: 'auto_update', label: 'Auto-update events' },
{ id: 'git_stack', label: 'Git stack events' },
{ id: 'stack', label: 'Stack events' },
{ id: 'security', label: 'Security events' },
{ id: 'backup', label: 'Backup events' },
{ id: 'system', label: 'System events' }
] as const;
// Helper to get system-only events (configured at channel level, not per-environment)
export const SYSTEM_NOTIFICATION_EVENTS = NOTIFICATION_EVENT_TYPES.filter(e => e.scope === 'system');
// Helper to get environment-scoped events (configured per-environment)
export const ENVIRONMENT_NOTIFICATION_EVENTS = NOTIFICATION_EVENT_TYPES.filter(e => e.scope === 'environment');
export type NotificationEventType = typeof NOTIFICATION_EVENT_TYPES[number]['id'];
const environmentEventIds = new Set(ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id));
/** Strip system-scoped events (e.g. license_expiring) from environment notification records */
function filterEnvironmentEventTypes(eventTypes: string[]): string[] {
return eventTypes.filter(id => environmentEventIds.has(id));
}
export interface NotificationSettingData {
id: number;
type: 'smtp' | 'apprise';
name: string;
enabled: boolean;
config: any;
eventTypes: NotificationEventType[];
createdAt: string;
updatedAt: string;
}
export interface SmtpConfig {
host: string;
port: number;
secure: boolean;
username?: string;
password?: string;
from_email: string;
from_name?: string;
to_emails: string[];
skipTlsVerify?: boolean; // Skip TLS certificate verification (useful for self-signed certs)
}
export interface AppriseConfig {
urls: string[];
}
// Helper to encrypt sensitive fields in notification config
function encryptNotificationConfig(type: 'smtp' | 'apprise', config: SmtpConfig | AppriseConfig): string {
if (type === 'smtp') {
const smtpConfig = config as SmtpConfig;
return JSON.stringify({
...smtpConfig,
password: encrypt(smtpConfig.password)
});
}
return JSON.stringify(config);
}
// Helper to decrypt sensitive fields in notification config
function decryptNotificationConfig(type: string, configJson: string): any {
const config = JSON.parse(configJson);
if (type === 'smtp' && config.password) {
return {
...config,
password: decrypt(config.password)
};
}
return config;
}
export async function getNotificationSettings(): Promise<NotificationSettingData[]> {
const rows = await db.select().from(notificationSettings).orderBy(desc(notificationSettings.createdAt));
return rows.map((row: typeof notificationSettings.$inferSelect) => ({
...row,
config: decryptNotificationConfig(row.type, row.config),
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
})) as NotificationSettingData[];
}
export async function getNotificationSetting(id: number): Promise<NotificationSettingData | null> {
const results = await db.select().from(notificationSettings).where(eq(notificationSettings.id, id));
if (!results[0]) return null;
const row = results[0];
return {
...row,
config: decryptNotificationConfig(row.type, row.config),
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
} as NotificationSettingData;
}
export async function getEnabledNotificationSettings(): Promise<NotificationSettingData[]> {
const rows = await db.select().from(notificationSettings).where(eq(notificationSettings.enabled, true));
return rows.map((row: typeof notificationSettings.$inferSelect) => ({
...row,
config: decryptNotificationConfig(row.type, row.config),
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
})) as NotificationSettingData[];
}
export async function createNotificationSetting(data: {
type: 'smtp' | 'apprise';
name: string;
enabled?: boolean;
config: SmtpConfig | AppriseConfig;
eventTypes?: NotificationEventType[];
}): Promise<NotificationSettingData> {
const eventTypes = data.eventTypes || NOTIFICATION_EVENT_TYPES.map(e => e.id);
const result = await db.insert(notificationSettings).values({
type: data.type,
name: data.name,
enabled: data.enabled !== false,
config: encryptNotificationConfig(data.type, data.config),
eventTypes: JSON.stringify(eventTypes)
}).returning();
return getNotificationSetting(result[0].id) as Promise<NotificationSettingData>;
}
export async function updateNotificationSetting(id: number, data: {
name?: string;
enabled?: boolean;
config?: SmtpConfig | AppriseConfig;
eventTypes?: NotificationEventType[];
}): Promise<NotificationSettingData | null> {
const existing = await getNotificationSetting(id);
if (!existing) return null;
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.name !== undefined) updateData.name = data.name;
if (data.enabled !== undefined) updateData.enabled = data.enabled;
if (data.config !== undefined) updateData.config = encryptNotificationConfig(existing.type, data.config);
if (data.eventTypes !== undefined) updateData.eventTypes = JSON.stringify(data.eventTypes);
await db.update(notificationSettings).set(updateData).where(eq(notificationSettings.id, id));
return getNotificationSetting(id);
}
export async function deleteNotificationSetting(id: number): Promise<boolean> {
// First delete all environment notifications that reference this notification channel
await db.delete(environmentNotifications).where(eq(environmentNotifications.notificationId, id));
// Then delete the notification setting itself
await db.delete(notificationSettings).where(eq(notificationSettings.id, id));
return true;
}
// =============================================================================
// ENVIRONMENT NOTIFICATION SETTINGS
// =============================================================================
export interface EnvironmentNotificationData {
id: number;
environmentId: number;
notificationId: number;
enabled: boolean;
eventTypes: NotificationEventType[];
createdAt: string;
updatedAt: string;
channelName?: string;
channelType?: 'smtp' | 'apprise';
channelEnabled?: boolean;
}
export async function getEnvironmentNotifications(environmentId: number): Promise<EnvironmentNotificationData[]> {
const rows = await db.select({
id: environmentNotifications.id,
environmentId: environmentNotifications.environmentId,
notificationId: environmentNotifications.notificationId,
enabled: environmentNotifications.enabled,
eventTypes: environmentNotifications.eventTypes,
createdAt: environmentNotifications.createdAt,
updatedAt: environmentNotifications.updatedAt,
channelName: notificationSettings.name,
channelType: notificationSettings.type,
channelEnabled: notificationSettings.enabled
})
.from(environmentNotifications)
.innerJoin(notificationSettings, eq(environmentNotifications.notificationId, notificationSettings.id))
.where(eq(environmentNotifications.environmentId, environmentId))
.orderBy(asc(notificationSettings.name));
return rows.map((row: any) => ({
...row,
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
})) as EnvironmentNotificationData[];
}
export async function getEnvironmentNotification(environmentId: number, notificationId: number): Promise<EnvironmentNotificationData | null> {
const rows = await db.select({
id: environmentNotifications.id,
environmentId: environmentNotifications.environmentId,
notificationId: environmentNotifications.notificationId,
enabled: environmentNotifications.enabled,
eventTypes: environmentNotifications.eventTypes,
createdAt: environmentNotifications.createdAt,
updatedAt: environmentNotifications.updatedAt,
channelName: notificationSettings.name,
channelType: notificationSettings.type,
channelEnabled: notificationSettings.enabled
})
.from(environmentNotifications)
.innerJoin(notificationSettings, eq(environmentNotifications.notificationId, notificationSettings.id))
.where(and(
eq(environmentNotifications.environmentId, environmentId),
eq(environmentNotifications.notificationId, notificationId)
));
if (!rows[0]) return null;
return {
...rows[0],
eventTypes: filterEnvironmentEventTypes(rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
} as EnvironmentNotificationData;
}
export async function createEnvironmentNotification(data: {
environmentId: number;
notificationId: number;
enabled?: boolean;
eventTypes?: NotificationEventType[];
}): Promise<EnvironmentNotificationData> {
const eventTypes = data.eventTypes || ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id);
await db.insert(environmentNotifications).values({
environmentId: data.environmentId,
notificationId: data.notificationId,
enabled: data.enabled !== false,
eventTypes: JSON.stringify(eventTypes)
});
return getEnvironmentNotification(data.environmentId, data.notificationId) as Promise<EnvironmentNotificationData>;
}
export async function updateEnvironmentNotification(environmentId: number, notificationId: number, data: {
enabled?: boolean;
eventTypes?: NotificationEventType[];
}): Promise<EnvironmentNotificationData | null> {
const existing = await getEnvironmentNotification(environmentId, notificationId);
if (!existing) return null;
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.enabled !== undefined) updateData.enabled = data.enabled;
if (data.eventTypes !== undefined) updateData.eventTypes = JSON.stringify(data.eventTypes);
await db.update(environmentNotifications)
.set(updateData)
.where(and(
eq(environmentNotifications.environmentId, environmentId),
eq(environmentNotifications.notificationId, notificationId)
));
return getEnvironmentNotification(environmentId, notificationId);
}
export async function deleteEnvironmentNotification(environmentId: number, notificationId: number): Promise<boolean> {
await db.delete(environmentNotifications)
.where(and(
eq(environmentNotifications.environmentId, environmentId),
eq(environmentNotifications.notificationId, notificationId)
));
return true;
}
export async function getEnabledEnvironmentNotifications(
environmentId: number,
eventType?: NotificationEventType
): Promise<(EnvironmentNotificationData & { config: any })[]> {
const rows = await db.select({
id: environmentNotifications.id,
environmentId: environmentNotifications.environmentId,
notificationId: environmentNotifications.notificationId,
enabled: environmentNotifications.enabled,
eventTypes: environmentNotifications.eventTypes,
createdAt: environmentNotifications.createdAt,
updatedAt: environmentNotifications.updatedAt,
channelName: notificationSettings.name,
channelType: notificationSettings.type,
channelEnabled: notificationSettings.enabled,
config: notificationSettings.config
})
.from(environmentNotifications)
.innerJoin(notificationSettings, eq(environmentNotifications.notificationId, notificationSettings.id))
.where(and(
eq(environmentNotifications.environmentId, environmentId),
eq(environmentNotifications.enabled, true),
eq(notificationSettings.enabled, true)
));
return rows
.map(row => ({
...row,
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id)),
config: decryptNotificationConfig(row.channelType ?? 'apprise', row.config)
}))
.filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[];
}
// =============================================================================
// AUTHENTICATION TYPES AND OPERATIONS
// =============================================================================
export interface Permissions {
containers: string[];
images: string[];
volumes: string[];
networks: string[];
stacks: string[];
environments: string[];
registries: string[];
notifications: string[];
configsets: string[];
settings: string[];
users: string[];
git: string[];
license: string[];
audit_logs: string[];
activity: string[];
schedules: string[];
secrets: string[];
backups: string[];
}
export interface AuthSettingsData {
id: number;
authEnabled: boolean;
defaultProvider: 'local' | 'ldap' | 'oidc';
sessionTimeout: number;
createdAt: string;
updatedAt: string;
}
export async function getAuthSettings(): Promise<AuthSettingsData> {
const results = await db.select().from(authSettings).limit(1);
return results[0] as AuthSettingsData;
}
export async function updateAuthSettings(data: Partial<AuthSettingsData>): Promise<AuthSettingsData> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.authEnabled !== undefined) updateData.authEnabled = data.authEnabled;
if (data.defaultProvider !== undefined) updateData.defaultProvider = data.defaultProvider;
if (data.sessionTimeout !== undefined) {
// 0 is the "never expire" sentinel (#1302); otherwise clamp 1s..30 days.
const MAX_SESSION_TIMEOUT = 2592000; // 30 days in seconds
updateData.sessionTimeout = data.sessionTimeout === 0
? 0
: Math.min(Math.max(1, data.sessionTimeout), MAX_SESSION_TIMEOUT);
}
// Get existing row's id (may not be 1 after db reset/migration)
const existing = await db.select({ id: authSettings.id }).from(authSettings).limit(1);
if (existing[0]) {
await db.update(authSettings).set(updateData).where(eq(authSettings.id, existing[0].id));
}
return getAuthSettings();
}
// =============================================================================
// USER OPERATIONS
// =============================================================================
export interface UserData {
id: number;
username: string;
email?: string | null;
passwordHash: string;
displayName?: string | null;
avatar?: string | null;
authProvider?: string | null;
mfaEnabled: boolean;
mfaSecret?: string | null;
isActive: boolean;
lastLogin?: string | null;
createdAt: string;
updatedAt: string;
}
export async function getUsers(): Promise<UserData[]> {
return db.select().from(users).orderBy(asc(users.username)) as Promise<UserData[]>;
}
export async function getUser(id: number): Promise<UserData | null> {
const results = await db.select().from(users).where(eq(users.id, id));
return results[0] as UserData || null;
}
export interface SafeUserData {
id: number;
username: string;
email: string | null;
displayName: string | null;
avatar: string | null;
authProvider: string | null;
mfaEnabled: boolean;
isActive: boolean;
lastLogin: string | null;
createdAt: string;
updatedAt: string;
}
export async function getUserWithoutPassword(id: number): Promise<SafeUserData | null> {
const results = await db.select({
id: users.id,
username: users.username,
email: users.email,
displayName: users.displayName,
avatar: users.avatar,
authProvider: users.authProvider,
mfaEnabled: users.mfaEnabled,
isActive: users.isActive,
lastLogin: users.lastLogin,
createdAt: users.createdAt,
updatedAt: users.updatedAt
}).from(users).where(eq(users.id, id));
return results[0] as SafeUserData || null;
}
export async function hasAdminUser(): Promise<boolean> {
// Check if any user has the Admin role assigned
const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1);
if (!adminRole[0]) return false;
const result = await db.select({ id: userRoles.id })
.from(userRoles)
.where(eq(userRoles.roleId, adminRole[0].id))
.limit(1);
return result.length > 0;
}
/**
* Whether any user account exists at all. Used to bound the "create the first account
* with no auth" allowance to a genuine first run - once ANY user exists, account
* creation goes through the normal authenticated path. (hasAdminUser only tracks the
* Admin role, which can be absent while user rows still exist.)
*/
export async function hasAnyUser(): Promise<boolean> {
const result = await db.select({ id: users.id }).from(users).limit(1);
return result.length > 0;
}
export async function countAdminUsers(): Promise<number> {
// Import license check dynamically to avoid circular dependencies
const { isEnterprise } = await import('./license');
const enterprise = await isEnterprise();
if (enterprise) {
// ENTERPRISE: Count users who have the Admin role assigned
const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1);
if (!adminRole[0]) return 0;
const results = await db.select({ count: sql<number>`count(DISTINCT ${userRoles.userId})` })
.from(userRoles)
.where(eq(userRoles.roleId, adminRole[0].id));
// PostgreSQL returns bigint for count, ensure we return a number
return Number(results[0]?.count ?? 0);
} else {
// FREE: Any user is effectively an admin (no RBAC), just count all users
const results = await db.select({ count: sql<number>`count(*)` }).from(users);
// PostgreSQL returns bigint for count, ensure we return a number
return Number(results[0]?.count ?? 0);
}
}
export async function getUserByUsername(username: string): Promise<UserData | null> {
const results = await db.select().from(users).where(eq(users.username, username));
return results[0] as UserData || null;
}
export async function createUser(data: {
username: string;
email?: string;
passwordHash: string;
displayName?: string;
authProvider?: string;
}): Promise<UserData> {
const result = await db.insert(users).values({
username: data.username,
email: data.email || null,
passwordHash: data.passwordHash,
displayName: data.displayName || null,
authProvider: data.authProvider || 'local'
}).returning();
return getUser(result[0].id) as Promise<UserData>;
}
export async function updateUser(id: number, data: Partial<UserData>): Promise<UserData | null> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.username !== undefined) updateData.username = data.username;
if (data.email !== undefined) updateData.email = data.email || null;
if (data.passwordHash !== undefined) updateData.passwordHash = data.passwordHash;
if (data.displayName !== undefined) updateData.displayName = data.displayName || null;
if (data.avatar !== undefined) updateData.avatar = data.avatar || null;
if (data.authProvider !== undefined) updateData.authProvider = data.authProvider;
if (data.mfaEnabled !== undefined) updateData.mfaEnabled = data.mfaEnabled;
if (data.mfaSecret !== undefined) updateData.mfaSecret = data.mfaSecret || null;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.lastLogin !== undefined) updateData.lastLogin = data.lastLogin;
await db.update(users).set(updateData).where(eq(users.id, id));
return getUser(id);
}
export async function deleteUser(id: number): Promise<boolean> {
await db.delete(users).where(eq(users.id, id));
return true;
}
// =============================================================================
// SESSION OPERATIONS
// =============================================================================
export interface SessionData {
id: string;
userId: number;
provider: string;
expiresAt: string;
createdAt: string;
}
export async function createSession(id: string, userId: number, provider: string, expiresAt: string): Promise<SessionData> {
await db.insert(sessions).values({
id,
userId,
provider,
expiresAt
});
return getSession(id) as Promise<SessionData>;
}
export async function getSession(id: string): Promise<SessionData | null> {
const results = await db.select().from(sessions).where(eq(sessions.id, id));
return results[0] as SessionData || null;
}
export async function deleteSession(id: string): Promise<boolean> {
await db.delete(sessions).where(eq(sessions.id, id));
return true;
}
export async function deleteExpiredSessions(): Promise<number> {
const now = new Date().toISOString();
await db.delete(sessions).where(sql`expires_at < ${now}`);
return 0; // Drizzle doesn't return changes count easily
}
export async function deleteUserSessions(userId: number): Promise<number> {
await db.delete(sessions).where(eq(sessions.userId, userId));
return 0;
}
// =============================================================================
// ROLE OPERATIONS
// =============================================================================
export interface RoleData {
id: number;
name: string;
description?: string | null;
isSystem: boolean;
permissions: Permissions;
environmentIds: number[] | null; // null = all environments, array = specific env IDs
createdAt: string;
updatedAt: string;
}
export async function getRoles(): Promise<RoleData[]> {
const rows = await db.select().from(roles).orderBy(asc(roles.name));
return rows.map(row => ({
...row,
permissions: JSON.parse(row.permissions),
environmentIds: row.environmentIds ? JSON.parse(row.environmentIds) : null
})) as RoleData[];
}
export async function getRole(id: number): Promise<RoleData | null> {
const results = await db.select().from(roles).where(eq(roles.id, id));
if (!results[0]) return null;
return {
...results[0],
permissions: JSON.parse(results[0].permissions),
environmentIds: results[0].environmentIds ? JSON.parse(results[0].environmentIds) : null
} as RoleData;
}
export async function getRoleByName(name: string): Promise<RoleData | null> {
const results = await db.select().from(roles).where(eq(roles.name, name));
if (!results[0]) return null;
return {
...results[0],
permissions: JSON.parse(results[0].permissions),
environmentIds: results[0].environmentIds ? JSON.parse(results[0].environmentIds) : null
} as RoleData;
}
export async function createRole(data: {
name: string;
description?: string;
permissions: Permissions;
environmentIds?: number[] | null;
}): Promise<RoleData> {
const result = await db.insert(roles).values({
name: data.name,
description: data.description || null,
isSystem: false,
permissions: JSON.stringify(data.permissions),
environmentIds: data.environmentIds ? JSON.stringify(data.environmentIds) : null
}).returning();
return getRole(result[0].id) as Promise<RoleData>;
}
export async function updateRole(id: number, data: Partial<RoleData>): Promise<RoleData | null> {
const role = await getRole(id);
if (!role || role.isSystem) return null;
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description || null;
if (data.permissions !== undefined) updateData.permissions = JSON.stringify(data.permissions);
if (data.environmentIds !== undefined) {
updateData.environmentIds = data.environmentIds ? JSON.stringify(data.environmentIds) : null;
}
await db.update(roles).set(updateData).where(eq(roles.id, id));
return getRole(id);
}
export async function deleteRole(id: number): Promise<boolean> {
const role = await getRole(id);
if (!role || role.isSystem) return false;
await db.delete(roles).where(and(eq(roles.id, id), eq(roles.isSystem, false)));
return true;
}
// =============================================================================
// USER-ROLE OPERATIONS
// =============================================================================
export interface UserRoleData {
id: number;
userId: number;
roleId: number;
environmentId?: number | null;
createdAt: string;
role?: RoleData;
}
export async function getUserRoles(userId: number): Promise<UserRoleData[]> {
const rows = await db.select({
id: userRoles.id,
userId: userRoles.userId,
roleId: userRoles.roleId,
environmentId: userRoles.environmentId,
createdAt: userRoles.createdAt,
roleName: roles.name,
roleDescription: roles.description,
roleIsSystem: roles.isSystem,
rolePermissions: roles.permissions
})
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(eq(userRoles.userId, userId));
return rows.map(row => ({
id: row.id,
userId: row.userId,
roleId: row.roleId,
environmentId: row.environmentId,
createdAt: row.createdAt,
role: {
id: row.roleId,
name: row.roleName,
description: row.roleDescription,
isSystem: row.roleIsSystem,
permissions: JSON.parse(row.rolePermissions),
createdAt: row.createdAt,
updatedAt: row.createdAt
}
})) as UserRoleData[];
}
export async function assignUserRole(userId: number, roleId: number, environmentId?: number): Promise<UserRoleData> {
await db.insert(userRoles).values({
userId,
roleId,
environmentId: environmentId || null
}).onConflictDoNothing();
const results = await db.select().from(userRoles)
.where(and(
eq(userRoles.userId, userId),
eq(userRoles.roleId, roleId),
environmentId ? eq(userRoles.environmentId, environmentId) : isNull(userRoles.environmentId)
));
return results[0] as UserRoleData;
}
export async function removeUserRole(userId: number, roleId: number, environmentId?: number): Promise<boolean> {
await db.delete(userRoles)
.where(and(
eq(userRoles.userId, userId),
eq(userRoles.roleId, roleId),
environmentId ? eq(userRoles.environmentId, environmentId) : isNull(userRoles.environmentId)
));
return true;
}
/**
* Check if user has the Admin role assigned.
* This is the authoritative check for admin privileges (instead of users.isAdmin column).
*/
export async function userHasAdminRole(userId: number): Promise<boolean> {
const result = await db.select({ id: roles.id })
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(and(
eq(userRoles.userId, userId),
eq(roles.name, 'Admin')
))
.limit(1);
return result.length > 0;
}
/**
* Get environment IDs that a user can access based on their role assignments.
* Returns null if user has access to ALL environments (has at least one role with null environmentIds).
* Returns array of environment IDs if user has limited access.
* Returns empty array if user has no environment access.
*/
export async function getUserAccessibleEnvironments(userId: number): Promise<number[] | null> {
const rows = await db.select({
roleEnvironmentIds: roles.environmentIds
})
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(eq(userRoles.userId, userId));
const accessibleEnvIds: number[] = [];
for (const row of rows) {
// If any role has null environmentIds, user has access to all environments
if (row.roleEnvironmentIds === null) {
return null; // null means "all environments"
}
try {
const envIds: number[] = JSON.parse(row.roleEnvironmentIds);
accessibleEnvIds.push(...envIds);
} catch {
// If parsing fails, assume all environments
return null;
}
}
// Return unique environment IDs
return [...new Set(accessibleEnvIds)];
}
/**
* Get roles for a user that apply to a specific environment.
* Returns roles where environmentId is null (global) OR matches the specified environment.
*/
interface RoleEnvRow {
id: number;
userId: number;
roleId: number;
environmentId: number | null;
createdAt: string | null;
roleName: string;
roleDescription: string | null;
roleIsSystem: boolean;
rolePermissions: string;
roleEnvironmentIds: string | null;
}
/**
* Get user roles that apply to a specific environment.
* A role applies if:
* - role.environmentIds is NULL (applies to all environments), OR
* - role.environmentIds array contains the target environmentId
*/
export async function getUserRolesForEnvironment(userId: number, environmentId: number): Promise<UserRoleData[]> {
const rows = await db.select({
id: userRoles.id,
userId: userRoles.userId,
roleId: userRoles.roleId,
environmentId: userRoles.environmentId,
createdAt: userRoles.createdAt,
roleName: roles.name,
roleDescription: roles.description,
roleIsSystem: roles.isSystem,
rolePermissions: roles.permissions,
roleEnvironmentIds: roles.environmentIds
})
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(eq(userRoles.userId, userId)) as RoleEnvRow[];
// Filter roles that apply to this environment
// Role applies if environmentIds is null OR contains the environmentId
const filteredRows = rows.filter((row: RoleEnvRow) => {
if (row.roleEnvironmentIds === null) {
return true; // null means all environments
}
try {
const envIds: number[] = JSON.parse(row.roleEnvironmentIds);
return envIds.includes(environmentId);
} catch {
return true; // If parsing fails, assume all environments
}
});
return filteredRows.map((row: RoleEnvRow) => ({
id: row.id,
userId: row.userId,
roleId: row.roleId,
environmentId: row.environmentId,
createdAt: row.createdAt,
role: {
id: row.roleId,
name: row.roleName,
description: row.roleDescription,
isSystem: row.roleIsSystem,
permissions: JSON.parse(row.rolePermissions),
environmentIds: row.roleEnvironmentIds ? JSON.parse(row.roleEnvironmentIds) : null,
createdAt: row.createdAt,
updatedAt: row.createdAt
}
})) as UserRoleData[];
}
/**
* Check if a user can access a specific environment.
* Returns true if user has any role that applies to this environment.
* A role applies if role.environmentIds is null OR contains the environmentId.
*/
export async function userCanAccessEnvironment(userId: number, environmentId: number): Promise<boolean> {
const rows = await db.select({
id: userRoles.id,
roleEnvironmentIds: roles.environmentIds
})
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(eq(userRoles.userId, userId));
// Check if any assigned role applies to this environment
for (const row of rows) {
if (row.roleEnvironmentIds === null) {
return true; // null means all environments
}
try {
const envIds: number[] = JSON.parse(row.roleEnvironmentIds);
if (envIds.includes(environmentId)) {
return true;
}
} catch {
return true; // If parsing fails, assume all environments
}
}
return false;
}
// =============================================================================
// LDAP CONFIG OPERATIONS
// =============================================================================
export interface LdapRoleMapping {
groupDn: string;
roleId: number;
}
export interface LdapConfigData {
id: number;
name: string;
enabled: boolean;
serverUrl: string;
bindDn?: string | null;
bindPassword?: string | null;
baseDn: string;
userFilter: string;
usernameAttribute: string;
emailAttribute: string;
displayNameAttribute: string;
groupBaseDn?: string | null;
groupFilter?: string | null;
adminGroup?: string | null;
roleMappings?: LdapRoleMapping[] | null;
tlsEnabled: boolean;
tlsCa?: string | null;
createdAt: string;
updatedAt: string;
}
export async function getLdapConfigs(): Promise<LdapConfigData[]> {
const results = await db.select().from(ldapConfig).orderBy(asc(ldapConfig.name));
return results.map((row: any) => ({
...row,
bindPassword: decrypt(row.bindPassword),
roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : null
})) as LdapConfigData[];
}
export async function getLdapConfig(id: number): Promise<LdapConfigData | null> {
const results = await db.select().from(ldapConfig).where(eq(ldapConfig.id, id));
if (!results[0]) return null;
const row = results[0] as any;
return {
...row,
bindPassword: decrypt(row.bindPassword),
roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : null
} as LdapConfigData;
}
export async function createLdapConfig(data: Omit<LdapConfigData, 'id' | 'createdAt' | 'updatedAt'>): Promise<LdapConfigData> {
const result = await db.insert(ldapConfig).values({
name: data.name,
enabled: data.enabled,
serverUrl: data.serverUrl,
bindDn: data.bindDn || null,
bindPassword: encrypt(data.bindPassword) || null,
baseDn: data.baseDn,
userFilter: data.userFilter,
usernameAttribute: data.usernameAttribute,
emailAttribute: data.emailAttribute,
displayNameAttribute: data.displayNameAttribute,
groupBaseDn: data.groupBaseDn || null,
groupFilter: data.groupFilter || null,
adminGroup: data.adminGroup || null,
roleMappings: data.roleMappings ? JSON.stringify(data.roleMappings) : null,
tlsEnabled: data.tlsEnabled,
tlsCa: data.tlsCa || null
}).returning();
return getLdapConfig(result[0].id) as Promise<LdapConfigData>;
}
export async function updateLdapConfig(id: number, data: Partial<LdapConfigData>): Promise<LdapConfigData | null> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.name !== undefined) updateData.name = data.name;
if (data.enabled !== undefined) updateData.enabled = data.enabled;
if (data.serverUrl !== undefined) updateData.serverUrl = data.serverUrl;
if (data.bindDn !== undefined) updateData.bindDn = data.bindDn || null;
if (data.bindPassword !== undefined) updateData.bindPassword = encrypt(data.bindPassword) || null;
if (data.baseDn !== undefined) updateData.baseDn = data.baseDn;
if (data.userFilter !== undefined) updateData.userFilter = data.userFilter;
if (data.usernameAttribute !== undefined) updateData.usernameAttribute = data.usernameAttribute;
if (data.emailAttribute !== undefined) updateData.emailAttribute = data.emailAttribute;
if (data.displayNameAttribute !== undefined) updateData.displayNameAttribute = data.displayNameAttribute;
if (data.groupBaseDn !== undefined) updateData.groupBaseDn = data.groupBaseDn || null;
if (data.groupFilter !== undefined) updateData.groupFilter = data.groupFilter || null;
if (data.adminGroup !== undefined) updateData.adminGroup = data.adminGroup || null;
if (data.roleMappings !== undefined) updateData.roleMappings = data.roleMappings ? JSON.stringify(data.roleMappings) : null;
if (data.tlsEnabled !== undefined) updateData.tlsEnabled = data.tlsEnabled;
if (data.tlsCa !== undefined) updateData.tlsCa = data.tlsCa || null;
await db.update(ldapConfig).set(updateData).where(eq(ldapConfig.id, id));
return getLdapConfig(id);
}
export async function deleteLdapConfig(id: number): Promise<boolean> {
await db.delete(ldapConfig).where(eq(ldapConfig.id, id));
return true;
}
// =============================================================================
// OIDC CONFIG OPERATIONS
// =============================================================================
export interface OidcRoleMapping {
claimValue: string;
roleId: number;
}
export interface OidcConfigData {
id: number;
name: string;
enabled: boolean;
issuerUrl: string;
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: string;
usernameClaim: string;
emailClaim: string;
displayNameClaim: string;
adminClaim?: string | null;
adminValue?: string | null;
roleMappingsClaim?: string | null;
roleMappings?: OidcRoleMapping[] | null;
createdAt: string;
updatedAt: string;
}
export async function getOidcConfigs(): Promise<OidcConfigData[]> {
const rows = await db.select().from(oidcConfig).orderBy(asc(oidcConfig.name));
return rows.map(row => ({
...row,
clientSecret: decrypt(row.clientSecret) ?? '',
roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : undefined
})) as OidcConfigData[];
}
export async function getOidcConfig(id: number): Promise<OidcConfigData | null> {
const results = await db.select().from(oidcConfig).where(eq(oidcConfig.id, id));
if (!results[0]) return null;
return {
...results[0],
clientSecret: decrypt(results[0].clientSecret) ?? '',
roleMappings: results[0].roleMappings ? JSON.parse(results[0].roleMappings) : undefined
} as OidcConfigData;
}
export async function createOidcConfig(data: Omit<OidcConfigData, 'id' | 'createdAt' | 'updatedAt'>): Promise<OidcConfigData> {
const result = await db.insert(oidcConfig).values({
name: data.name,
enabled: data.enabled,
issuerUrl: data.issuerUrl,
clientId: data.clientId,
clientSecret: encrypt(data.clientSecret) ?? '',
redirectUri: data.redirectUri,
scopes: data.scopes,
usernameClaim: data.usernameClaim,
emailClaim: data.emailClaim,
displayNameClaim: data.displayNameClaim,
adminClaim: data.adminClaim || null,
adminValue: data.adminValue || null,
roleMappingsClaim: data.roleMappingsClaim || 'groups',
roleMappings: data.roleMappings ? JSON.stringify(data.roleMappings) : null
}).returning();
return getOidcConfig(result[0].id) as Promise<OidcConfigData>;
}
export async function updateOidcConfig(id: number, data: Partial<OidcConfigData>): Promise<OidcConfigData | null> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.name !== undefined) updateData.name = data.name;
if (data.enabled !== undefined) updateData.enabled = data.enabled;
if (data.issuerUrl !== undefined) updateData.issuerUrl = data.issuerUrl;
if (data.clientId !== undefined) updateData.clientId = data.clientId;
if (data.clientSecret !== undefined) updateData.clientSecret = encrypt(data.clientSecret);
if (data.redirectUri !== undefined) updateData.redirectUri = data.redirectUri;
if (data.scopes !== undefined) updateData.scopes = data.scopes;
if (data.usernameClaim !== undefined) updateData.usernameClaim = data.usernameClaim;
if (data.emailClaim !== undefined) updateData.emailClaim = data.emailClaim;
if (data.displayNameClaim !== undefined) updateData.displayNameClaim = data.displayNameClaim;
if (data.adminClaim !== undefined) updateData.adminClaim = data.adminClaim || null;
if (data.adminValue !== undefined) updateData.adminValue = data.adminValue || null;
if (data.roleMappingsClaim !== undefined) updateData.roleMappingsClaim = data.roleMappingsClaim || 'groups';
if (data.roleMappings !== undefined) updateData.roleMappings = data.roleMappings ? JSON.stringify(data.roleMappings) : null;
await db.update(oidcConfig).set(updateData).where(eq(oidcConfig.id, id));
return getOidcConfig(id);
}
export async function deleteOidcConfig(id: number): Promise<boolean> {
await db.delete(oidcConfig).where(eq(oidcConfig.id, id));
return true;
}
// =============================================================================
// GIT CREDENTIALS OPERATIONS
// =============================================================================
export type GitAuthType = 'none' | 'password' | 'ssh';
export interface GitCredentialData {
id: number;
name: string;
authType: GitAuthType;
username?: string | null;
password?: string | null;
sshPrivateKey?: string | null;
sshPassphrase?: string | null;
createdAt: string;
updatedAt: string;
}
export async function getGitCredentials(): Promise<GitCredentialData[]> {
const results = await db.select().from(gitCredentials).orderBy(asc(gitCredentials.name));
return results.map(r => ({
...r,
password: decrypt(r.password),
sshPrivateKey: decrypt(r.sshPrivateKey),
sshPassphrase: decrypt(r.sshPassphrase)
})) as GitCredentialData[];
}
export async function getGitCredential(id: number): Promise<GitCredentialData | null> {
const results = await db.select().from(gitCredentials).where(eq(gitCredentials.id, id));
if (!results[0]) return null;
return {
...results[0],
password: decrypt(results[0].password),
sshPrivateKey: decrypt(results[0].sshPrivateKey),
sshPassphrase: decrypt(results[0].sshPassphrase)
} as GitCredentialData;
}
export async function createGitCredential(data: {
name: string;
authType: GitAuthType;
username?: string;
password?: string;
sshPrivateKey?: string;
sshPassphrase?: string;
}): Promise<GitCredentialData> {
const result = await db.insert(gitCredentials).values({
name: data.name,
authType: data.authType,
username: data.username || null,
password: encrypt(data.password) || null,
sshPrivateKey: encrypt(data.sshPrivateKey) || null,
sshPassphrase: encrypt(data.sshPassphrase) || null
}).returning();
return getGitCredential(result[0].id) as Promise<GitCredentialData>;
}
export async function updateGitCredential(id: number, data: Partial<GitCredentialData>): Promise<GitCredentialData | null> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.name !== undefined) updateData.name = data.name;
if (data.authType !== undefined) updateData.authType = data.authType;
// Only update username if provided (empty string clears it)
if (data.username !== undefined) updateData.username = data.username || null;
// Only update password/ssh keys if they have actual values (preserve existing if empty)
if (data.password) updateData.password = encrypt(data.password);
if (data.sshPrivateKey) updateData.sshPrivateKey = encrypt(data.sshPrivateKey);
if (data.sshPassphrase) updateData.sshPassphrase = encrypt(data.sshPassphrase);
await db.update(gitCredentials).set(updateData).where(eq(gitCredentials.id, id));
return getGitCredential(id);
}
export async function deleteGitCredential(id: number): Promise<boolean> {
await db.delete(gitCredentials).where(eq(gitCredentials.id, id));
return true;
}
// =============================================================================
// GIT REPOSITORIES OPERATIONS
// =============================================================================
export type GitSyncStatus = 'pending' | 'syncing' | 'synced' | 'error';
export interface GitRepositoryData {
id: number;
name: string;
url: string;
branch: string;
composePath: string;
credentialId: number | null;
environmentId: number | null;
autoUpdate: boolean;
autoUpdateSchedule: 'daily' | 'weekly' | 'custom';
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
lastSync: string | null;
lastCommit: string | null;
syncStatus: GitSyncStatus;
syncError: string | null;
createdAt: string;
updatedAt: string;
}
export interface GitRepositoryWithCredential extends GitRepositoryData {
credential?: GitCredentialData | null;
}
export async function getGitRepositories(): Promise<GitRepositoryWithCredential[]> {
const rows = await db.select({
id: gitRepositories.id,
name: gitRepositories.name,
url: gitRepositories.url,
branch: gitRepositories.branch,
composePath: gitRepositories.composePath,
credentialId: gitRepositories.credentialId,
environmentId: gitRepositories.environmentId,
autoUpdate: gitRepositories.autoUpdate,
autoUpdateSchedule: gitRepositories.autoUpdateSchedule,
autoUpdateCron: gitRepositories.autoUpdateCron,
webhookEnabled: gitRepositories.webhookEnabled,
webhookSecret: gitRepositories.webhookSecret,
lastSync: gitRepositories.lastSync,
lastCommit: gitRepositories.lastCommit,
syncStatus: gitRepositories.syncStatus,
syncError: gitRepositories.syncError,
createdAt: gitRepositories.createdAt,
updatedAt: gitRepositories.updatedAt,
credentialName: gitCredentials.name,
credentialAuthType: gitCredentials.authType
})
.from(gitRepositories)
.leftJoin(gitCredentials, eq(gitRepositories.credentialId, gitCredentials.id))
.orderBy(asc(gitRepositories.name));
return rows.map(row => ({
...row,
credential: row.credentialId ? {
id: row.credentialId,
name: row.credentialName,
authType: row.credentialAuthType
} : null
})) as GitRepositoryWithCredential[];
}
export async function getGitRepository(id: number): Promise<GitRepositoryData | null> {
const results = await db.select().from(gitRepositories).where(eq(gitRepositories.id, id));
return results[0] as GitRepositoryData || null;
}
export async function getGitRepositoryByName(name: string): Promise<GitRepositoryData | null> {
const results = await db.select().from(gitRepositories).where(eq(gitRepositories.name, name));
return results[0] as GitRepositoryData || null;
}
export async function createGitRepository(data: {
name: string;
url: string;
branch?: string;
composePath?: string;
credentialId?: number | null;
environmentId?: number | null;
autoUpdate?: boolean;
autoUpdateSchedule?: 'daily' | 'weekly' | 'custom';
autoUpdateCron?: string;
webhookEnabled?: boolean;
webhookSecret?: string | null;
}): Promise<GitRepositoryData> {
const result = await db.insert(gitRepositories).values({
name: data.name,
url: data.url,
branch: data.branch || 'main',
composePath: data.composePath || 'compose.yaml',
credentialId: data.credentialId || null,
environmentId: data.environmentId || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: data.webhookSecret || null
}).returning();
return getGitRepository(result[0].id) as Promise<GitRepositoryData>;
}
export async function updateGitRepository(id: number, data: Partial<GitRepositoryData>): Promise<GitRepositoryData | null> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.name !== undefined) updateData.name = data.name;
if (data.url !== undefined) updateData.url = data.url;
if (data.branch !== undefined) updateData.branch = data.branch;
if (data.composePath !== undefined) updateData.composePath = data.composePath;
if (data.credentialId !== undefined) updateData.credentialId = data.credentialId;
if (data.environmentId !== undefined) updateData.environmentId = data.environmentId;
if (data.autoUpdate !== undefined) updateData.autoUpdate = data.autoUpdate;
if (data.autoUpdateSchedule !== undefined) updateData.autoUpdateSchedule = data.autoUpdateSchedule;
if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron;
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret;
if (data.lastSync !== undefined) updateData.lastSync = data.lastSync;
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
if (data.syncError !== undefined) updateData.syncError = data.syncError;
await db.update(gitRepositories).set(updateData).where(eq(gitRepositories.id, id));
return getGitRepository(id);
}
export async function getGitStacksByRepositoryId(repositoryId: number): Promise<Array<{ id: number; stackName: string; environmentId: number | null }>> {
return db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId
}).from(gitStacks).where(eq(gitStacks.repositoryId, repositoryId));
}
export async function deleteGitRepository(id: number): Promise<boolean> {
console.log(`[GitStack] Deleting git repository id=${id} (will cascade-delete git_stacks, set null on stack_sources FKs)`);
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
return true;
}
// =============================================================================
// GIT STACKS OPERATIONS
// =============================================================================
export interface GitStackData {
id: number;
stackName: string;
environmentId: number | null;
repositoryId: number;
composePath: string;
branch: string | null; // Per-stack branch override; null = use repository default
envFilePath: string | null;
autoUpdate: boolean;
autoUpdateSchedule: 'daily' | 'weekly' | 'custom';
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
contextDir: string | null;
buildOnDeploy: boolean;
noBuildCache: boolean;
repullImages: boolean;
forceRedeploy: boolean;
lastSync: string | null;
lastCommit: string | null;
syncStatus: GitSyncStatus;
syncError: string | null;
syncedFiles?: string | null; // JSON manifest { commit, files: { relPath: sha256 } } from last successful deploy
createdAt: string;
updatedAt: string;
}
export interface GitStackWithRepo extends GitStackData {
repository: {
id: number;
name: string;
url: string;
branch: string;
credentialId: number | null;
};
}
export async function getGitStacks(environmentId?: number): Promise<GitStackWithRepo[]> {
let rows;
if (environmentId !== undefined) {
rows = await db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
autoUpdateSchedule: gitStacks.autoUpdateSchedule,
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
repoUrl: gitRepositories.url,
repoBranch: gitRepositories.branch,
repoCredentialId: gitRepositories.credentialId
})
.from(gitStacks)
.innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id))
.where(or(eq(gitStacks.environmentId, environmentId), isNull(gitStacks.environmentId)))
.orderBy(asc(gitStacks.stackName));
} else {
rows = await db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
autoUpdateSchedule: gitStacks.autoUpdateSchedule,
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
repoUrl: gitRepositories.url,
repoBranch: gitRepositories.branch,
repoCredentialId: gitRepositories.credentialId
})
.from(gitStacks)
.innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id))
.orderBy(asc(gitStacks.stackName));
}
return rows.map(row => ({
id: row.id,
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
autoUpdateSchedule: row.autoUpdateSchedule,
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
syncError: row.syncError,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repository: {
id: row.repositoryId,
name: row.repoName,
url: row.repoUrl,
branch: row.repoBranch,
credentialId: row.repoCredentialId
}
})) as GitStackWithRepo[];
}
// Get git stacks for a specific environment only (excludes stacks with null environment)
export async function getGitStacksForEnvironmentOnly(environmentId: number): Promise<GitStackWithRepo[]> {
const rows = await db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
autoUpdateSchedule: gitStacks.autoUpdateSchedule,
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
repoUrl: gitRepositories.url,
repoBranch: gitRepositories.branch,
repoCredentialId: gitRepositories.credentialId
})
.from(gitStacks)
.innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id))
.where(eq(gitStacks.environmentId, environmentId))
.orderBy(asc(gitStacks.stackName));
return rows.map((row) => ({
id: row.id,
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
autoUpdateSchedule: row.autoUpdateSchedule,
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
syncError: row.syncError,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repository: {
id: row.repositoryId,
name: row.repoName,
url: row.repoUrl,
branch: row.repoBranch,
credentialId: row.repoCredentialId
}
})) as GitStackWithRepo[];
}
export async function getGitStack(id: number): Promise<GitStackWithRepo | null> {
const rows = await db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
autoUpdateSchedule: gitStacks.autoUpdateSchedule,
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
syncedFiles: gitStacks.syncedFiles,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
repoUrl: gitRepositories.url,
repoBranch: gitRepositories.branch,
repoCredentialId: gitRepositories.credentialId
})
.from(gitStacks)
.innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id))
.where(eq(gitStacks.id, id));
if (!rows[0]) return null;
const row = rows[0];
return {
id: row.id,
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
autoUpdateSchedule: row.autoUpdateSchedule,
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
syncError: row.syncError,
syncedFiles: row.syncedFiles ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repository: {
id: row.repositoryId,
name: row.repoName,
url: row.repoUrl,
branch: row.repoBranch,
credentialId: row.repoCredentialId
}
} as GitStackWithRepo;
}
export async function getGitStackByName(stackName: string, environmentId?: number | null): Promise<GitStackWithRepo | null> {
const rows = await db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
autoUpdateSchedule: gitStacks.autoUpdateSchedule,
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
repoUrl: gitRepositories.url,
repoBranch: gitRepositories.branch,
repoCredentialId: gitRepositories.credentialId
})
.from(gitStacks)
.innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id))
.where(and(
eq(gitStacks.stackName, stackName),
environmentId !== undefined && environmentId !== null
? eq(gitStacks.environmentId, environmentId)
: isNull(gitStacks.environmentId)
));
if (!rows[0]) return null;
const row = rows[0];
return {
id: row.id,
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
autoUpdateSchedule: row.autoUpdateSchedule,
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
syncError: row.syncError,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repository: {
id: row.repositoryId,
name: row.repoName,
url: row.repoUrl,
branch: row.repoBranch,
credentialId: row.repoCredentialId
}
} as GitStackWithRepo;
}
export async function getGitStackByWebhookSecret(secret: string): Promise<GitStackWithRepo | null> {
const rows = await db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
autoUpdateSchedule: gitStacks.autoUpdateSchedule,
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
repoUrl: gitRepositories.url,
repoBranch: gitRepositories.branch,
repoCredentialId: gitRepositories.credentialId
})
.from(gitStacks)
.innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id))
.where(and(eq(gitStacks.webhookSecret, secret), eq(gitStacks.webhookEnabled, true)));
if (!rows[0]) return null;
const row = rows[0];
return {
id: row.id,
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
autoUpdateSchedule: row.autoUpdateSchedule,
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
syncError: row.syncError,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repository: {
id: row.repositoryId,
name: row.repoName,
url: row.repoUrl,
branch: row.repoBranch,
credentialId: row.repoCredentialId
}
} as GitStackWithRepo;
}
export async function createGitStack(data: {
stackName: string;
environmentId?: number | null;
repositoryId: number;
branch?: string | null;
composePath?: string;
envFilePath?: string | null;
autoUpdate?: boolean;
autoUpdateSchedule?: 'daily' | 'weekly' | 'custom';
autoUpdateCron?: string;
webhookEnabled?: boolean;
webhookSecret?: string | null;
contextDir?: string | null;
buildOnDeploy?: boolean;
noBuildCache?: boolean;
repullImages?: boolean;
forceRedeploy?: boolean;
}): Promise<GitStackWithRepo> {
const result = await db.insert(gitStacks).values({
stackName: data.stackName,
environmentId: data.environmentId ?? null,
repositoryId: data.repositoryId,
branch: data.branch || null,
composePath: data.composePath || 'compose.yaml',
envFilePath: data.envFilePath || null,
contextDir: data.contextDir || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: data.webhookSecret || null,
buildOnDeploy: data.buildOnDeploy ?? false,
noBuildCache: data.noBuildCache ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
}).returning();
return getGitStack(result[0].id) as Promise<GitStackWithRepo>;
}
export async function updateGitStack(id: number, data: Partial<GitStackData>): Promise<GitStackWithRepo | null> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.stackName !== undefined) updateData.stackName = data.stackName;
if (data.repositoryId !== undefined) updateData.repositoryId = data.repositoryId;
if (data.composePath !== undefined) updateData.composePath = data.composePath;
if (data.branch !== undefined) updateData.branch = data.branch || null;
if (data.envFilePath !== undefined) updateData.envFilePath = data.envFilePath;
if (data.autoUpdate !== undefined) updateData.autoUpdate = data.autoUpdate;
if (data.autoUpdateSchedule !== undefined) updateData.autoUpdateSchedule = data.autoUpdateSchedule;
if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron;
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret;
if (data.contextDir !== undefined) updateData.contextDir = data.contextDir;
if (data.buildOnDeploy !== undefined) updateData.buildOnDeploy = data.buildOnDeploy;
if (data.noBuildCache !== undefined) updateData.noBuildCache = data.noBuildCache;
if (data.repullImages !== undefined) updateData.repullImages = data.repullImages;
if (data.forceRedeploy !== undefined) updateData.forceRedeploy = data.forceRedeploy;
if (data.lastSync !== undefined) updateData.lastSync = data.lastSync;
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
if (data.syncError !== undefined) updateData.syncError = data.syncError;
if (data.syncedFiles !== undefined) updateData.syncedFiles = data.syncedFiles;
await db.update(gitStacks).set(updateData).where(eq(gitStacks.id, id));
return getGitStack(id);
}
export async function deleteGitStack(id: number): Promise<boolean> {
console.log(`[GitStack] Deleting git_stacks row id=${id}`);
await db.delete(gitStacks).where(eq(gitStacks.id, id));
return true;
}
export async function renameGitStack(id: number, newName: string): Promise<boolean> {
await db.update(gitStacks)
.set({ stackName: newName, updatedAt: new Date().toISOString() })
.where(eq(gitStacks.id, id));
return true;
}
export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
const rows = await db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
autoUpdateSchedule: gitStacks.autoUpdateSchedule,
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
repoUrl: gitRepositories.url,
repoBranch: gitRepositories.branch,
repoCredentialId: gitRepositories.credentialId
})
.from(gitStacks)
.innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id))
.where(eq(gitStacks.autoUpdate, true));
return rows.map(row => ({
id: row.id,
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
autoUpdateSchedule: row.autoUpdateSchedule,
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
syncError: row.syncError,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repository: {
id: row.repositoryId,
name: row.repoName,
url: row.repoUrl,
branch: row.repoBranch,
credentialId: row.repoCredentialId
}
})) as GitStackWithRepo[];
}
export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
const rows = await db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
composePath: gitStacks.composePath,
autoUpdate: gitStacks.autoUpdate,
autoUpdateSchedule: gitStacks.autoUpdateSchedule,
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
repoUrl: gitRepositories.url,
repoBranch: gitRepositories.branch,
repoCredentialId: gitRepositories.credentialId
})
.from(gitStacks)
.innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id))
.where(eq(gitStacks.autoUpdate, true));
return rows.map(row => ({
id: row.id,
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
autoUpdate: row.autoUpdate,
autoUpdateSchedule: row.autoUpdateSchedule,
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
syncError: row.syncError,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repository: {
id: row.repositoryId,
name: row.repoName,
url: row.repoUrl,
branch: row.repoBranch,
credentialId: row.repoCredentialId
}
})) as GitStackWithRepo[];
}
// =============================================================================
// STACK SOURCES OPERATIONS
// =============================================================================
export type StackSourceType = 'external' | 'internal' | 'git';
export interface StackSourceData {
id: number;
stackName: string;
environmentId: number | null;
sourceType: StackSourceType;
gitRepositoryId: number | null;
gitStackId: number | null;
composePath: string | null;
envPath: string | null;
secretProviderId: number | null;
icon: string | null;
createdAt: string;
updatedAt: string;
}
export interface StackSourceWithRepo extends StackSourceData {
repository?: GitRepositoryData | null;
gitStack?: GitStackWithRepo | null;
}
export async function getStackSource(stackName: string, environmentId?: number | null): Promise<StackSourceWithRepo | null> {
const results = await db.select().from(stackSources)
.where(and(
eq(stackSources.stackName, stackName),
environmentId !== undefined && environmentId !== null
? eq(stackSources.environmentId, environmentId)
: isNull(stackSources.environmentId)
));
if (!results[0]) return null;
const row = results[0];
let repository = null;
let gitStackData = null;
if (row.gitRepositoryId) {
repository = await getGitRepository(row.gitRepositoryId);
}
if (row.gitStackId) {
gitStackData = await getGitStack(row.gitStackId);
}
return {
...row,
repository,
gitStack: gitStackData
} as StackSourceWithRepo;
}
export async function getStackSourceByComposePath(composePath: string, environmentId?: number | null): Promise<StackSourceWithRepo | null> {
const envCondition = environmentId !== undefined && environmentId !== null
? eq(stackSources.environmentId, environmentId)
: isNull(stackSources.environmentId);
const results = await db.select().from(stackSources)
.where(and(eq(stackSources.composePath, composePath), envCondition));
if (!results[0]) return null;
const row = results[0];
let repository = null;
let gitStackData = null;
if (row.gitRepositoryId) {
repository = await getGitRepository(row.gitRepositoryId);
}
if (row.gitStackId) {
gitStackData = await getGitStack(row.gitStackId);
}
return {
...row,
repository,
gitStack: gitStackData
} as StackSourceWithRepo;
}
export async function getStackSources(environmentId?: number | null): Promise<StackSourceWithRepo[]> {
let results;
if (environmentId !== undefined && environmentId !== null) {
// Only get stacks for the specific environment
results = await db.select().from(stackSources)
.where(eq(stackSources.environmentId, environmentId))
.orderBy(asc(stackSources.stackName));
} else {
results = await db.select().from(stackSources).orderBy(asc(stackSources.stackName));
}
const enrichedResults: StackSourceWithRepo[] = [];
for (const row of results) {
let repository = null;
let gitStackData = null;
if (row.gitRepositoryId) {
repository = await getGitRepository(row.gitRepositoryId);
}
if (row.gitStackId) {
gitStackData = await getGitStack(row.gitStackId);
}
enrichedResults.push({
...row,
repository,
gitStack: gitStackData
} as StackSourceWithRepo);
}
return enrichedResults;
}
export async function upsertStackSource(data: {
stackName: string;
environmentId?: number | null;
sourceType: StackSourceType;
gitRepositoryId?: number | null;
gitStackId?: number | null;
composePath?: string | null;
envPath?: string | null;
secretProviderId?: number | null;
icon?: string | null;
}): Promise<StackSourceData> {
const existing = await getStackSource(data.stackName, data.environmentId);
if (existing) {
const newRepoId = data.gitRepositoryId || null;
const newStackId = data.gitStackId || null;
const changes: string[] = [];
if (data.sourceType !== existing.sourceType) changes.push(`sourceType: ${existing.sourceType} → ${data.sourceType}`);
if (newRepoId !== existing.gitRepositoryId) changes.push(`gitRepoId: ${existing.gitRepositoryId} → ${newRepoId}`);
if (newStackId !== existing.gitStackId) changes.push(`gitStackId: ${existing.gitStackId} → ${newStackId}`);
if (changes.length > 0) {
console.log(`[GitStack] Updating stack_sources "${data.stackName}" env=${data.environmentId}: ${changes.join(', ')}`);
}
await db.update(stackSources)
.set({
sourceType: data.sourceType,
gitRepositoryId: newRepoId,
gitStackId: newStackId,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null,
updatedAt: new Date().toISOString(),
// Preserve existing binding when caller (like git) omits it
...(data.secretProviderId !== undefined && { secretProviderId: data.secretProviderId }),
// Same preserve-on-omit for the icon, so a git sync doesn't wipe a user's choice
...(data.icon !== undefined && { icon: data.icon })
})
.where(eq(stackSources.id, existing.id));
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
} else {
console.log(`[GitStack] Creating stack_sources "${data.stackName}" env=${data.environmentId} type=${data.sourceType} repoId=${data.gitRepositoryId || null} stackId=${data.gitStackId || null}`);
await db.insert(stackSources).values({
stackName: data.stackName,
environmentId: data.environmentId ?? null,
sourceType: data.sourceType,
gitRepositoryId: data.gitRepositoryId || null,
gitStackId: data.gitStackId || null,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null,
secretProviderId: data.secretProviderId ?? null,
icon: data.icon ?? null
});
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
}
}
export async function updateStackSource(
stackName: string,
environmentId: number | null,
updates: { composePath?: string | null; envPath?: string | null; secretProviderId?: number | null; icon?: string | null }
): Promise<boolean> {
const existing = await getStackSource(stackName, environmentId);
if (!existing) return false;
await db.update(stackSources)
.set({
composePath: updates.composePath !== undefined ? updates.composePath : existing.composePath,
envPath: updates.envPath !== undefined ? updates.envPath : existing.envPath,
secretProviderId: updates.secretProviderId !== undefined ? updates.secretProviderId : existing.secretProviderId,
icon: updates.icon !== undefined ? updates.icon : existing.icon,
updatedAt: new Date().toISOString()
})
.where(eq(stackSources.id, existing.id));
return true;
}
/**
* Persist the names (no values) of secret keys injected from the bound provider on
* the last deploy, so container inspect can mask them without a live provider call.
* Stored as a JSON array on stack_sources; null clears it. No-op if the stack has
* no source row.
*/
export async function setStackInjectedSecretKeys(
stackName: string,
environmentId: number | null | undefined,
keys: string[]
): Promise<void> {
const existing = await getStackSource(stackName, environmentId ?? null);
if (!existing) return;
await db.update(stackSources)
.set({
injectedSecretKeys: serializeInjectedSecretKeys(keys),
updatedAt: new Date().toISOString()
})
.where(eq(stackSources.id, existing.id));
}
/**
* Read back the provider-injected secret key names for a stack (empty if none).
* Tolerates a malformed/legacy value by returning an empty set.
*/
export async function getStackInjectedSecretKeys(
stackName: string,
environmentId?: number | null
): Promise<Set<string>> {
const source = await getStackSource(stackName, environmentId ?? null);
const raw = (source as { injectedSecretKeys?: string | null } | null)?.injectedSecretKeys;
return parseInjectedSecretKeys(raw);
}
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`);
// Delete matching record (either with specific envId or NULL)
await db.delete(stackSources)
.where(and(
eq(stackSources.stackName, stackName),
environmentId !== undefined && environmentId !== null
? eq(stackSources.environmentId, environmentId)
: isNull(stackSources.environmentId)
));
// Also cleanup any orphaned records with NULL environment_id for this stack
// This handles cases where stacks were created with wrong/missing environment association
if (environmentId !== undefined && environmentId !== null) {
await db.delete(stackSources)
.where(and(
eq(stackSources.stackName, stackName),
isNull(stackSources.environmentId)
));
}
return true;
}
export async function updateStackSourceName(
oldStackName: string,
newStackName: string,
environmentId?: number | null
): Promise<boolean> {
await db.update(stackSources)
.set({
stackName: newStackName,
updatedAt: new Date().toISOString()
})
.where(and(
eq(stackSources.stackName, oldStackName),
environmentId !== undefined && environmentId !== null
? eq(stackSources.environmentId, environmentId)
: isNull(stackSources.environmentId)
));
return true;
}
// =============================================================================
// CONTAINER ICON OVERRIDES
// =============================================================================
// A user-picked icon for a container, keyed by (name, env). Absent -> the UI's
// automatic image/name matching applies. The value is a lucide name,
// 'selfhst:<ref>', or 'custom:container' (bytes on disk via container-icons.ts).
function containerEnvClause(environmentId: number | null) {
return environmentId !== null
? eq(containerIconOverrides.environmentId, environmentId)
: isNull(containerIconOverrides.environmentId);
}
/** The icon override for one container, or null if none is set. */
export async function getContainerIconOverride(containerName: string, environmentId: number | null): Promise<string | null> {
const rows = await db.select().from(containerIconOverrides)
.where(and(eq(containerIconOverrides.containerName, containerName), containerEnvClause(environmentId)))
.limit(1);
return rows[0]?.icon ?? null;
}
/** All overrides for an environment as a name->icon map, for the list page (no N+1). */
export async function getContainerIconOverrides(environmentId: number | null): Promise<Record<string, string>> {
const rows = await db.select().from(containerIconOverrides).where(containerEnvClause(environmentId));
const map: Record<string, string> = {};
for (const row of rows) map[row.containerName] = row.icon;
return map;
}
/** Upsert the icon override for a container. */
export async function setContainerIconOverride(containerName: string, environmentId: number | null, icon: string): Promise<void> {
const existing = await getContainerIconOverride(containerName, environmentId);
if (existing !== null) {
await db.update(containerIconOverrides)
.set({ icon, updatedAt: new Date().toISOString() })
.where(and(eq(containerIconOverrides.containerName, containerName), containerEnvClause(environmentId)));
} else {
await db.insert(containerIconOverrides).values({ containerName, environmentId, icon });
}
}
/** Remove a container's icon override (fall back to automatic matching). */
export async function deleteContainerIconOverride(containerName: string, environmentId: number | null): Promise<void> {
await db.delete(containerIconOverrides)
.where(and(eq(containerIconOverrides.containerName, containerName), containerEnvClause(environmentId)));
}
// =============================================================================
// VULNERABILITY SCAN RESULTS
// =============================================================================
export interface VulnerabilityScanData {
id: number;
environmentId: number | null;
imageId: string;
imageName: string;
scanner: 'grype' | 'trivy';
scannedAt: string;
scanDuration: number;
criticalCount: number;
highCount: number;
mediumCount: number;
lowCount: number;
negligibleCount: number;
unknownCount: number;
vulnerabilities: any[];
error: string | null;
createdAt: string;
}
export async function saveVulnerabilityScan(data: {
environmentId?: number | null;
imageId: string;
imageName: string;
scanner: 'grype' | 'trivy';
scannedAt: string;
scanDuration: number;
criticalCount: number;
highCount: number;
mediumCount: number;
lowCount: number;
negligibleCount: number;
unknownCount: number;
vulnerabilities: any[];
error?: string | null;
}): Promise<VulnerabilityScanData> {
const result = await db.insert(vulnerabilityScans).values({
environmentId: data.environmentId ?? null,
imageId: data.imageId,
imageName: data.imageName,
scanner: data.scanner,
scannedAt: data.scannedAt,
scanDuration: data.scanDuration,
criticalCount: data.criticalCount,
highCount: data.highCount,
mediumCount: data.mediumCount,
lowCount: data.lowCount,
negligibleCount: data.negligibleCount,
unknownCount: data.unknownCount,
vulnerabilities: JSON.stringify(data.vulnerabilities),
error: data.error ?? null
}).returning();
// A new scan makes the dashboard's cached findings stale — drop them so every
// writer (routes + schedulers) refreshes it, no separate call to remember.
invalidateVulnerabilitiesCache(data.environmentId ?? undefined);
return getVulnerabilityScan(result[0].id) as Promise<VulnerabilityScanData>;
}
export async function getVulnerabilityScan(id: number): Promise<VulnerabilityScanData | null> {
const results = await db.select().from(vulnerabilityScans).where(eq(vulnerabilityScans.id, id));
if (!results[0]) return null;
return {
...results[0],
vulnerabilities: results[0].vulnerabilities ? JSON.parse(results[0].vulnerabilities) : []
} as VulnerabilityScanData;
}
export async function getLatestScanForImage(
imageId: string,
scanner?: string,
environmentId?: number | null
): Promise<VulnerabilityScanData | null> {
let conditions = [eq(vulnerabilityScans.imageId, imageId)];
if (scanner) {
conditions.push(eq(vulnerabilityScans.scanner, scanner as 'grype' | 'trivy'));
}
if (environmentId !== undefined) {
if (environmentId === null) {
conditions.push(isNull(vulnerabilityScans.environmentId));
} else {
conditions.push(eq(vulnerabilityScans.environmentId, environmentId));
}
}
const results = await db.select().from(vulnerabilityScans)
.where(and(...conditions))
.orderBy(desc(vulnerabilityScans.scannedAt))
.limit(1);
if (!results[0]) return null;
return {
...results[0],
vulnerabilities: results[0].vulnerabilities ? JSON.parse(results[0].vulnerabilities) : []
} as VulnerabilityScanData;
}
/**
* Delete all previous scan rows for a specific image + scanner in an environment.
* Used by "scan all" to replace an image's prior scan rather than accumulate rows.
* Returns the number of rows removed.
*/
export async function deleteScansForImageScanner(
imageId: string,
scanner: 'grype' | 'trivy',
environmentId?: number | null
): Promise<number> {
const conditions = [
eq(vulnerabilityScans.imageId, imageId),
eq(vulnerabilityScans.scanner, scanner)
];
if (environmentId === null || environmentId === undefined) {
conditions.push(isNull(vulnerabilityScans.environmentId));
} else {
conditions.push(eq(vulnerabilityScans.environmentId, environmentId));
}
const result = await db.delete(vulnerabilityScans).where(and(...conditions)).returning({ id: vulnerabilityScans.id });
return result.length;
}
export async function getScansForImage(
imageId: string,
environmentId?: number | null,
limit = 10
): Promise<VulnerabilityScanData[]> {
// Scope by environment so a caller can't read another environment's scans for
// the same image SHA (the vulnerability_scans table is per-environment). When
// environmentId is omitted (undefined) all environments are returned — callers
// exposed to untrusted input MUST pass a concrete env.
const conditions = [eq(vulnerabilityScans.imageId, imageId)];
if (environmentId !== undefined) {
conditions.push(environmentId === null
? isNull(vulnerabilityScans.environmentId)
: eq(vulnerabilityScans.environmentId, environmentId));
}
const results = await db.select().from(vulnerabilityScans)
.where(and(...conditions))
.orderBy(desc(vulnerabilityScans.scannedAt))
.limit(limit);
return results.map(row => ({
...row,
vulnerabilities: row.vulnerabilities ? JSON.parse(row.vulnerabilities) : []
})) as VulnerabilityScanData[];
}
/**
* Get the combined scan summary for an image across all scanners.
* When using "both" scanners, this returns the MAX counts per severity
* from the latest scan of each scanner type.
*/
export async function getCombinedScanForImage(
imageId: string,
environmentId?: number | null
): Promise<{ critical: number; high: number; medium: number; low: number; negligible: number; unknown: number } | null> {
let conditions = [eq(vulnerabilityScans.imageId, imageId)];
if (environmentId !== undefined) {
if (environmentId === null) {
conditions.push(isNull(vulnerabilityScans.environmentId));
} else {
conditions.push(eq(vulnerabilityScans.environmentId, environmentId));
}
}
// Get all scans for this image (we'll group by scanner in JS)
const results = await db.select().from(vulnerabilityScans)
.where(and(...conditions))
.orderBy(desc(vulnerabilityScans.scannedAt));
if (results.length === 0) return null;
// Get the latest scan for each scanner
const latestByScanner = new Map<string, typeof results[0]>();
for (const scan of results) {
if (!latestByScanner.has(scan.scanner)) {
latestByScanner.set(scan.scanner, scan);
}
}
// Combine using MAX per severity (same logic as combineScanSummaries)
let combined = { critical: 0, high: 0, medium: 0, low: 0, negligible: 0, unknown: 0 };
for (const scan of latestByScanner.values()) {
combined.critical = Math.max(combined.critical, scan.criticalCount ?? 0);
combined.high = Math.max(combined.high, scan.highCount ?? 0);
combined.medium = Math.max(combined.medium, scan.mediumCount ?? 0);
combined.low = Math.max(combined.low, scan.lowCount ?? 0);
combined.negligible = Math.max(combined.negligible, scan.negligibleCount ?? 0);
combined.unknown = Math.max(combined.unknown, scan.unknownCount ?? 0);
}
return combined;
}
export async function getAllLatestScans(environmentId?: number | null): Promise<VulnerabilityScanData[]> {
// This complex query requires raw SQL or multiple queries
// For simplicity, we'll fetch all and filter in JS
let results;
if (environmentId !== undefined) {
if (environmentId === null) {
results = await db.select().from(vulnerabilityScans)
.where(isNull(vulnerabilityScans.environmentId))
.orderBy(desc(vulnerabilityScans.scannedAt));
} else {
results = await db.select().from(vulnerabilityScans)
.where(eq(vulnerabilityScans.environmentId, environmentId))
.orderBy(desc(vulnerabilityScans.scannedAt));
}
} else {
results = await db.select().from(vulnerabilityScans)
.orderBy(desc(vulnerabilityScans.scannedAt));
}
// Group by imageId + scanner and take latest
const latestMap = new Map<string, typeof results[0]>();
for (const row of results) {
const key = `${row.imageId}:${row.scanner}`;
if (!latestMap.has(key)) {
latestMap.set(key, row);
}
}
return Array.from(latestMap.values()).map(row => ({
...row,
vulnerabilities: row.vulnerabilities ? JSON.parse(row.vulnerabilities) : []
})) as VulnerabilityScanData[];
}
/**
* Scan freshness for the metrics endpoint: how stale the OLDEST scan is
* (surfaces environments whose scans have gone stale) and the average scan
* duration (surfaces slow scanners). Ages/durations in seconds; nulls when there
* are no scans.
*
* A pure SQL aggregate over only scanned_at / scan_duration — it deliberately
* does NOT touch the large `vulnerabilities` JSON blob (unlike getAllLatestScans),
* so it stays cheap when called per-environment on every scrape.
*/
export async function getScanFreshness(environmentId?: number | null): Promise<{
scans: number; oldestAgeSeconds: number | null; avgDurationSeconds: number | null;
}> {
const envCond = environmentId === undefined
? undefined
: environmentId === null
? isNull(vulnerabilityScans.environmentId)
: eq(vulnerabilityScans.environmentId, environmentId);
const rows = await db
.select({
n: sql<number>`count(*)`,
oldest: sql<string | null>`min(${vulnerabilityScans.scannedAt})`,
avgDur: sql<number | null>`avg(${vulnerabilityScans.scanDuration})`
})
.from(vulnerabilityScans)
.where(envCond);
const r = rows[0];
const n = Number(r?.n ?? 0);
if (n === 0) return { scans: 0, oldestAgeSeconds: null, avgDurationSeconds: null };
const oldestT = r?.oldest ? new Date(r.oldest).getTime() : NaN;
const oldestAgeSeconds = Number.isNaN(oldestT) ? null : Math.max(0, Math.round((Date.now() - oldestT) / 1000));
const avgDurationSeconds = r?.avgDur != null ? Math.round(Number(r.avgDur) / 1000) : null;
return { scans: n, oldestAgeSeconds, avgDurationSeconds };
}
export async function deleteOldScans(keepDays = 30): Promise<number> {
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(vulnerabilityScans)
.where(sql`scanned_at < ${cutoffDate}`);
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(vulnerabilityScans)
.where(sql`scanned_at < ${cutoffDate}`);
}
return count;
}
// =============================================================================
// AUDIT LOGGING (Enterprise Feature)
// =============================================================================
export type AuditAction =
| 'create' | 'update' | 'delete' | 'start' | 'stop' | 'restart' | 'down'
| 'pause' | 'unpause' | 'pull' | 'push' | 'prune' | 'login'
| 'logout' | 'view' | 'exec' | 'connect' | 'disconnect' | 'deploy' | 'sync' | 'rename' | 'webhook'
| 'backup' | 'restore' | 'verify';
export type AuditEntityType =
| 'container' | 'image' | 'stack' | 'volume' | 'network'
| 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential'
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack' | 'api_token'
| 'secret_provider' | 'backup_destination' | 'backup_config';
export interface AuditLogData {
id: number;
userId: number | null;
username: string;
action: AuditAction;
entityType: AuditEntityType;
entityId: string | null;
entityName: string | null;
environmentId: number | null;
description: string | null;
details: any | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
}
export interface AuditLogCreateData {
userId?: number | null;
username: string;
action: AuditAction;
entityType: AuditEntityType;
entityId?: string | null;
entityName?: string | null;
environmentId?: number | null;
description?: string | null;
details?: any | null;
ipAddress?: string | null;
userAgent?: string | null;
}
export interface AuditLogFilters {
username?: string;
usernames?: string[];
entityType?: AuditEntityType;
entityTypes?: AuditEntityType[];
action?: AuditAction;
actions?: AuditAction[];
environmentId?: number;
labels?: string[]; // Filter by environment labels (audit entries from envs with ANY of these labels)
fromDate?: string;
toDate?: string;
limit?: number;
offset?: number;
}
export interface AuditLogResult {
logs: AuditLogData[];
total: number;
limit: number;
offset: number;
}
export async function logAuditEvent(data: AuditLogCreateData): Promise<AuditLogData> {
const result = await db.insert(auditLogs).values({
userId: data.userId ?? null,
username: data.username,
action: data.action,
entityType: data.entityType,
entityId: data.entityId ?? null,
entityName: data.entityName ?? null,
environmentId: data.environmentId ?? null,
description: data.description ?? null,
details: data.details ? JSON.stringify(data.details) : null,
ipAddress: data.ipAddress ?? null,
userAgent: data.userAgent ?? null
}).returning();
const auditLog = await getAuditLog(result[0].id);
// Broadcast the new audit event to connected SSE clients
try {
const { broadcastAuditEvent } = await import('./audit-events.js');
broadcastAuditEvent(auditLog!);
} catch (e) {
// Ignore broadcast errors
}
return auditLog!;
}
export async function getAuditLog(id: number): Promise<(AuditLogData & { environmentName?: string | null; environmentIcon?: string | null }) | undefined> {
const results = await db.select({
id: auditLogs.id,
userId: auditLogs.userId,
username: auditLogs.username,
action: auditLogs.action,
entityType: auditLogs.entityType,
entityId: auditLogs.entityId,
entityName: auditLogs.entityName,
environmentId: auditLogs.environmentId,
description: auditLogs.description,
details: auditLogs.details,
ipAddress: auditLogs.ipAddress,
userAgent: auditLogs.userAgent,
createdAt: auditLogs.createdAt,
environmentName: environments.name,
environmentIcon: environments.icon
})
.from(auditLogs)
.leftJoin(environments, eq(auditLogs.environmentId, environments.id))
.where(eq(auditLogs.id, id));
if (!results[0]) return undefined;
return {
...results[0],
details: results[0].details ? JSON.parse(results[0].details) : null
} as AuditLogData & { environmentName?: string | null; environmentIcon?: string | null };
}
export async function getAuditLogs(filters: AuditLogFilters = {}): Promise<AuditLogResult> {
let conditions: any[] = [];
// Labels filter - find environments with matching labels first
let labelFilteredEnvIds: number[] | undefined;
if (filters.labels && filters.labels.length > 0) {
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
labelFilteredEnvIds = allEnvs
.filter(env => {
if (!env.labels) return false;
try {
const envLabels = JSON.parse(env.labels) as string[];
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
})
.map(env => env.id);
// If no environments match the labels, return empty result
if (labelFilteredEnvIds.length === 0) {
return { logs: [], total: 0, limit: filters.limit || 50, offset: filters.offset || 0 };
}
}
if (filters.usernames && filters.usernames.length > 0) {
conditions.push(inArray(auditLogs.username, filters.usernames));
} else if (filters.username) {
conditions.push(eq(auditLogs.username, filters.username));
}
if (filters.entityTypes && filters.entityTypes.length > 0) {
conditions.push(inArray(auditLogs.entityType, filters.entityTypes));
} else if (filters.entityType) {
conditions.push(eq(auditLogs.entityType, filters.entityType));
}
if (filters.actions && filters.actions.length > 0) {
conditions.push(inArray(auditLogs.action, filters.actions));
} else if (filters.action) {
conditions.push(eq(auditLogs.action, filters.action));
}
if (filters.environmentId !== undefined && filters.environmentId !== null) {
// If we also have label filtering, verify this environment has matching labels
if (labelFilteredEnvIds && !labelFilteredEnvIds.includes(filters.environmentId)) {
return { logs: [], total: 0, limit: filters.limit || 50, offset: filters.offset || 0 };
}
conditions.push(eq(auditLogs.environmentId, filters.environmentId));
} else if (labelFilteredEnvIds) {
// Only label filter (no specific environment filter)
conditions.push(inArray(auditLogs.environmentId, labelFilteredEnvIds));
}
if (filters.fromDate) {
conditions.push(sql`${auditLogs.createdAt} >= ${filters.fromDate}`);
}
if (filters.toDate) {
conditions.push(sql`${auditLogs.createdAt} <= ${filters.toDate}`);
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
// Get total count
const countResult = await db.select({ count: sql<number>`count(*)` }).from(auditLogs)
.where(whereClause);
const total = Number(countResult[0]?.count) || 0;
// Get paginated results
const limit = filters.limit || 50;
const offset = filters.offset || 0;
const rows = await db.select({
id: auditLogs.id,
userId: auditLogs.userId,
username: auditLogs.username,
action: auditLogs.action,
entityType: auditLogs.entityType,
entityId: auditLogs.entityId,
entityName: auditLogs.entityName,
environmentId: auditLogs.environmentId,
description: auditLogs.description,
details: auditLogs.details,
ipAddress: auditLogs.ipAddress,
userAgent: auditLogs.userAgent,
createdAt: auditLogs.createdAt,
environmentName: environments.name,
environmentIcon: environments.icon
})
.from(auditLogs)
.leftJoin(environments, eq(auditLogs.environmentId, environments.id))
.where(whereClause)
.orderBy(desc(auditLogs.createdAt))
.limit(limit)
.offset(offset);
const logs = rows.map(row => ({
...row,
details: row.details ? JSON.parse(row.details) : null,
timestamp: row.createdAt
})) as AuditLogData[];
return { logs, total, limit, offset };
}
export async function getAuditLogUsers(): Promise<string[]> {
const results = await db.selectDistinct({ username: auditLogs.username }).from(auditLogs).orderBy(asc(auditLogs.username));
return results.map(row => row.username);
}
export async function deleteOldAuditLogs(keepDays = 90): Promise<number> {
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
await db.delete(auditLogs)
.where(sql`created_at < ${cutoffDate}`);
return 0;
}
// =============================================================================
// CONTAINER ACTIVITY (Docker Events) - Free Feature
// =============================================================================
export type ContainerEventAction =
| 'create' | 'start' | 'stop' | 'die' | 'kill' | 'restart'
| 'pause' | 'unpause' | 'destroy' | 'rename' | 'update'
| 'attach' | 'detach' | 'exec_create' | 'exec_start' | 'exec_die'
| 'health_status' | 'oom';
export interface ContainerEventData {
id: number;
environmentId: number | null;
containerId: string;
containerName: string | null;
image: string | null;
action: ContainerEventAction;
actorAttributes: Record<string, string> | null;
timestamp: string;
createdAt: string;
}
export interface ContainerEventCreateData {
environmentId?: number | null;
containerId: string;
containerName?: string | null;
image?: string | null;
action: ContainerEventAction;
actorAttributes?: Record<string, string> | null;
timestamp: string; // ISO string with nanosecond precision for proper event ordering
}
export interface ContainerEventFilters {
environmentId?: number | null;
environmentIds?: number[]; // Filter by multiple environments (for permission filtering)
labels?: string[]; // Filter by environment labels (events from envs with ANY of these labels)
containerId?: string;
containerName?: string;
actions?: ContainerEventAction[];
fromDate?: string;
toDate?: string;
limit?: number;
offset?: number;
}
export interface ContainerEventResult {
events: ContainerEventData[];
total: number;
limit: number;
offset: number;
}
export async function logContainerEvent(
data: ContainerEventCreateData
): Promise<ContainerEventData> {
const attrs = data.actorAttributes ? JSON.stringify(data.actorAttributes) : null;
const [inserted] = await db.insert(containerEvents).values({
environmentId: data.environmentId ?? null,
containerId: data.containerId,
containerName: data.containerName ?? null,
image: data.image ?? null,
action: data.action,
actorAttributes: attrs,
timestamp: data.timestamp
}).returning({ id: containerEvents.id });
const event = await getContainerEvent(inserted.id);
return event!;
}
export async function getContainerEvent(id: number): Promise<ContainerEventData | undefined> {
const rows = await db.select({
id: containerEvents.id,
environmentId: containerEvents.environmentId,
containerId: containerEvents.containerId,
containerName: containerEvents.containerName,
image: containerEvents.image,
action: containerEvents.action,
actorAttributes: containerEvents.actorAttributes,
timestamp: containerEvents.timestamp,
createdAt: containerEvents.createdAt,
environmentName: environments.name,
environmentIcon: environments.icon
})
.from(containerEvents)
.leftJoin(environments, eq(containerEvents.environmentId, environments.id))
.where(eq(containerEvents.id, id));
if (!rows[0]) return undefined;
return {
...rows[0],
actorAttributes: rows[0].actorAttributes ? JSON.parse(rows[0].actorAttributes) : null
} as ContainerEventData;
}
export async function getContainerEvents(filters: ContainerEventFilters = {}): Promise<ContainerEventResult> {
let conditions: any[] = [];
// Labels filter - find environments with matching labels first
let labelFilteredEnvIds: number[] | undefined;
if (filters.labels && filters.labels.length > 0) {
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
labelFilteredEnvIds = allEnvs
.filter(env => {
if (!env.labels) return false;
try {
const envLabels = JSON.parse(env.labels) as string[];
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
})
.map(env => env.id);
// If no environments match the labels, return empty result
if (labelFilteredEnvIds.length === 0) {
return { events: [], total: 0, limit: filters.limit || 100, offset: filters.offset || 0 };
}
}
// Single environment filter takes precedence
if (filters.environmentId !== undefined && filters.environmentId !== null) {
conditions.push(eq(containerEvents.environmentId, filters.environmentId));
} else if (filters.environmentIds && filters.environmentIds.length > 0) {
// Multiple environments filter (for permission-based filtering)
// If we also have label filtering, intersect the two sets
if (labelFilteredEnvIds) {
const intersected = filters.environmentIds.filter(id => labelFilteredEnvIds!.includes(id));
if (intersected.length === 0) {
return { events: [], total: 0, limit: filters.limit || 100, offset: filters.offset || 0 };
}
conditions.push(inArray(containerEvents.environmentId, intersected));
} else {
conditions.push(inArray(containerEvents.environmentId, filters.environmentIds));
}
} else if (labelFilteredEnvIds) {
// Only label filter (no environment filter)
conditions.push(inArray(containerEvents.environmentId, labelFilteredEnvIds));
}
if (filters.containerId) {
conditions.push(eq(containerEvents.containerId, filters.containerId));
}
if (filters.containerName) {
conditions.push(like(containerEvents.containerName, `%${filters.containerName}%`));
}
if (filters.actions && filters.actions.length > 0) {
conditions.push(inArray(containerEvents.action, filters.actions));
}
if (filters.fromDate) {
conditions.push(sql`${containerEvents.timestamp} >= ${filters.fromDate}`);
}
if (filters.toDate) {
conditions.push(sql`${containerEvents.timestamp} <= ${filters.toDate}`);
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
// Get total count
const countResult = await db.select({ count: sql<number>`count(*)` }).from(containerEvents)
.where(whereClause);
const total = Number(countResult[0]?.count) || 0;
// Get paginated results
const limit = filters.limit || 100;
const offset = filters.offset || 0;
const rows = await db.select({
id: containerEvents.id,
environmentId: containerEvents.environmentId,
containerId: containerEvents.containerId,
containerName: containerEvents.containerName,
image: containerEvents.image,
action: containerEvents.action,
actorAttributes: containerEvents.actorAttributes,
timestamp: containerEvents.timestamp,
createdAt: containerEvents.createdAt,
environmentName: environments.name,
environmentIcon: environments.icon
})
.from(containerEvents)
.leftJoin(environments, eq(containerEvents.environmentId, environments.id))
.where(whereClause)
.orderBy(desc(containerEvents.timestamp))
.limit(limit)
.offset(offset);
const events = rows.map(row => ({
...row,
actorAttributes: row.actorAttributes ? JSON.parse(row.actorAttributes) : null
})) as ContainerEventData[];
return { events, total, limit, offset };
}
export async function getContainerEventContainers(environmentId?: number | null, environmentIds?: number[]): Promise<string[]> {
let whereClause;
if (environmentId !== undefined && environmentId !== null) {
whereClause = and(isNotNull(containerEvents.containerName), eq(containerEvents.environmentId, environmentId));
} else if (environmentIds && environmentIds.length > 0) {
whereClause = and(isNotNull(containerEvents.containerName), inArray(containerEvents.environmentId, environmentIds));
} else {
whereClause = isNotNull(containerEvents.containerName);
}
const results = await db.selectDistinct({ containerName: containerEvents.containerName })
.from(containerEvents)
.where(whereClause)
.orderBy(asc(containerEvents.containerName));
return results.map(row => row.containerName!).filter(Boolean);
}
export async function getContainerEventActions(): Promise<string[]> {
const results = await db.selectDistinct({ action: containerEvents.action })
.from(containerEvents)
.orderBy(asc(containerEvents.action));
return results.map(row => row.action);
}
export async function deleteOldContainerEvents(keepDays = 30): Promise<number> {
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(containerEvents)
.where(sql`timestamp < ${cutoffDate}`);
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(containerEvents)
.where(sql`timestamp < ${cutoffDate}`);
}
return count;
}
/**
* Run volume helper cleanup (wrapper for scheduler).
* Dynamically imports docker.ts to avoid circular dependencies.
*/
export async function runVolumeHelperCleanup(): Promise<void> {
const { cleanupStaleVolumeHelpers, cleanupExpiredVolumeHelpers } = await import('./docker');
await cleanupStaleVolumeHelpers();
await cleanupExpiredVolumeHelpers();
}
export async function clearContainerEvents(): Promise<void> {
await db.delete(containerEvents);
}
export async function getContainerEventStats(environmentId?: number | null, environmentIds?: number[]): Promise<{
total: number;
today: number;
byAction: Record<string, number>;
}> {
let baseConditions: any[] = [];
if (environmentId !== undefined && environmentId !== null) {
baseConditions.push(eq(containerEvents.environmentId, environmentId));
} else if (environmentIds && environmentIds.length > 0) {
baseConditions.push(inArray(containerEvents.environmentId, environmentIds));
}
const baseWhere = baseConditions.length > 0 ? and(...baseConditions) : undefined;
// Total count
const totalResult = await db.select({ count: sql<number>`count(*)` })
.from(containerEvents)
.where(baseWhere);
// Today's count - use start of today in ISO format
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayConditions = [...baseConditions, sql`timestamp >= ${todayStart.toISOString()}`];
const todayResult = await db.select({ count: sql<number>`count(*)` })
.from(containerEvents)
.where(and(...todayConditions));
// Count by action
const actionResults = await db.select({
action: containerEvents.action,
count: sql<number>`count(*)`
})
.from(containerEvents)
.where(baseWhere)
.groupBy(containerEvents.action);
const byAction: Record<string, number> = {};
for (const row of actionResults) {
byAction[row.action] = Number(row.count) || 0;
}
return {
total: Number(totalResult[0]?.count) || 0,
today: Number(todayResult[0]?.count) || 0,
byAction
};
}
// =============================================================================
// DASHBOARD PREFERENCES
// =============================================================================
export interface GridItem {
id: number;
x: number;
y: number;
w: number;
h: number;
}
// =============================================================================
// USER PREFERENCES OPERATIONS (unified key-value store)
// =============================================================================
export interface UserPreferenceIdentifier {
userId?: number | null; // NULL = shared (free edition)
environmentId?: number | null; // NULL = global preference
key: string;
}
/**
* Get a user preference value
*/
export async function getUserPreference<T>(
identifier: UserPreferenceIdentifier
): Promise<T | null> {
const { userId, environmentId, key } = identifier;
let query = db.select().from(userPreferences).where(eq(userPreferences.key, key));
if (userId) {
query = query.where(eq(userPreferences.userId, userId));
} else {
query = query.where(isNull(userPreferences.userId));
}
if (environmentId) {
query = query.where(eq(userPreferences.environmentId, environmentId));
} else {
query = query.where(isNull(userPreferences.environmentId));
}
const results = await query;
if (!results[0]) return null;
try {
return JSON.parse(results[0].value) as T;
} catch {
return results[0].value as T;
}
}
/**
* Set a user preference value (upsert)
*/
export async function setUserPreference<T>(
identifier: UserPreferenceIdentifier,
value: T
): Promise<void> {
const { userId, environmentId, key } = identifier;
const jsonValue = JSON.stringify(value);
const now = new Date().toISOString();
// Check if exists
const existing = await getUserPreference(identifier);
if (existing !== null) {
// Update
let updateQuery = db.update(userPreferences)
.set({ value: jsonValue, updatedAt: now })
.where(eq(userPreferences.key, key));
if (userId) {
updateQuery = updateQuery.where(eq(userPreferences.userId, userId));
} else {
updateQuery = updateQuery.where(isNull(userPreferences.userId));
}
if (environmentId) {
updateQuery = updateQuery.where(eq(userPreferences.environmentId, environmentId));
} else {
updateQuery = updateQuery.where(isNull(userPreferences.environmentId));
}
await updateQuery;
} else {
// Insert
await db.insert(userPreferences).values({
userId: userId ?? null,
environmentId: environmentId ?? null,
key,
value: jsonValue
});
}
}
/**
* Delete a user preference
*/
export async function deleteUserPreference(
identifier: UserPreferenceIdentifier
): Promise<void> {
const { userId, environmentId, key } = identifier;
let query = db.delete(userPreferences).where(eq(userPreferences.key, key));
if (userId) {
query = query.where(eq(userPreferences.userId, userId));
} else {
query = query.where(isNull(userPreferences.userId));
}
if (environmentId) {
query = query.where(eq(userPreferences.environmentId, environmentId));
} else {
query = query.where(isNull(userPreferences.environmentId));
}
await query;
}
// =============================================================================
// DASHBOARD PREFERENCES (uses unified userPreferences table)
// =============================================================================
export interface DashboardPreferencesData {
userId: number | null;
gridLayout: GridItem[];
}
const DASHBOARD_LAYOUT_KEY = 'dashboard_layout';
export async function getDashboardPreferences(userId?: number | null): Promise<DashboardPreferencesData | null> {
const gridLayout = await getUserPreference<GridItem[]>({
userId,
environmentId: null,
key: DASHBOARD_LAYOUT_KEY
});
if (!gridLayout) return null;
return {
userId: userId ?? null,
gridLayout
};
}
export async function saveDashboardPreferences(data: {
userId?: number | null;
gridLayout: GridItem[];
}): Promise<DashboardPreferencesData> {
await setUserPreference(
{ userId: data.userId, environmentId: null, key: DASHBOARD_LAYOUT_KEY },
data.gridLayout
);
return {
userId: data.userId ?? null,
gridLayout: data.gridLayout
};
}
// =============================================================================
// SCHEDULE EXECUTION OPERATIONS
// =============================================================================
export type ScheduleType = 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'restore' | 'stack_deploy' | 'deploy_log_reconcile';
export type ScheduleTrigger = 'cron' | 'webhook' | 'manual' | 'startup';
export type ScheduleStatus =
| 'queued'
| 'running'
| 'success'
| 'failed'
| 'skipped'
// Richer terminal states used by the backup/restore operation records:
// 'warning' = completed with a caveat (e.g. a partial read); 'error' = ran and
// failed (with a machine code in details); 'cancelled' = aborted by the user;
// 'stale' = interrupted by a process restart (re-run it). The status column is
// free text, so these coexist with the legacy values.
| 'warning'
| 'error'
| 'cancelled'
| 'stale';
export interface ScheduleExecutionData {
id: number;
scheduleType: ScheduleType;
scheduleId: number;
environmentId: number | null;
entityName: string;
triggeredBy: ScheduleTrigger;
triggeredAt: string;
startedAt: string | null;
completedAt: string | null;
duration: number | null;
status: ScheduleStatus;
errorMessage: string | null;
details: any | null;
logs: string | null;
createdAt: string | null;
}
export interface ScheduleExecutionCreateData {
scheduleType: ScheduleType;
scheduleId: number;
environmentId?: number | null;
entityName: string;
triggeredBy: ScheduleTrigger;
status?: ScheduleStatus;
details?: any;
}
export interface ScheduleExecutionUpdateData {
status?: ScheduleStatus;
startedAt?: string;
completedAt?: string;
duration?: number;
errorMessage?: string | null;
details?: any;
logs?: string;
}
export interface ScheduleExecutionFilters {
scheduleType?: ScheduleType;
scheduleId?: number;
environmentId?: number | null;
// Powers "runs for this stack" lookups (schedule_executions_entity_env_idx) --
// paired with environmentId, an exact match on the (entity_name, environment_id)
// index this filter was added for.
entityName?: string;
status?: ScheduleStatus;
statuses?: ScheduleStatus[];
triggeredBy?: ScheduleTrigger;
fromDate?: string;
toDate?: string;
limit?: number;
offset?: number;
}
export interface ScheduleExecutionResult {
executions: ScheduleExecutionData[];
total: number;
limit: number;
offset: number;
}
export async function createScheduleExecution(data: ScheduleExecutionCreateData): Promise<ScheduleExecutionData> {
const now = new Date().toISOString();
const result = await db.insert(scheduleExecutions).values({
scheduleType: data.scheduleType,
scheduleId: data.scheduleId,
environmentId: data.environmentId ?? null,
entityName: data.entityName,
triggeredBy: data.triggeredBy,
triggeredAt: now,
status: data.status || 'queued',
details: data.details ? JSON.stringify(data.details) : null
}).returning();
return {
...result[0],
details: data.details || null
} as ScheduleExecutionData;
}
export async function updateScheduleExecution(id: number, data: ScheduleExecutionUpdateData): Promise<ScheduleExecutionData | undefined> {
const updateData: Record<string, any> = {};
if (data.status !== undefined) updateData.status = data.status;
if (data.startedAt !== undefined) updateData.startedAt = data.startedAt;
if (data.completedAt !== undefined) updateData.completedAt = data.completedAt;
if (data.duration !== undefined) updateData.duration = data.duration;
if (data.errorMessage !== undefined) updateData.errorMessage = data.errorMessage;
if (data.details !== undefined) updateData.details = JSON.stringify(data.details);
if (data.logs !== undefined) updateData.logs = data.logs;
await db.update(scheduleExecutions).set(updateData).where(eq(scheduleExecutions.id, id));
return getScheduleExecution(id);
}
/**
* Mark any execution still in a non-terminal state ('queued'/'running') as
* 'failed'. Called on startup (audit #49/#50): a process crash mid-backup leaves
* the row 'running' forever, so it shows as perpetually in-progress in the UI and
* the config looks busy. Returns the number of rows swept.
*/
export async function failStaleRunningExecutions(): Promise<number> {
const now = new Date().toISOString();
const res = await db.update(scheduleExecutions)
.set({
status: 'failed',
completedAt: now,
errorMessage: 'Interrupted — Dockhand restarted while this run was in progress'
})
.where(inArray(scheduleExecutions.status, ['queued', 'running']))
.returning({ id: scheduleExecutions.id });
return res.length;
}
export async function appendScheduleExecutionLog(id: number, logLine: string): Promise<void> {
// Atomic SQL-side concatenation. Previous implementation (SELECT current logs,
// concat in JS, UPDATE) raced when adjacent log() callers didn't await each
// other — two near-simultaneous appends would both read the same `logs`
// snapshot and the second UPDATE would silently overwrite the first,
// dropping log lines. Observable as intermittently missing options in
// backup logs (e.g. Upload limit fired but Download limit silently dropped,
// even though both code paths ran).
//
// `||` is the SQL string-concat operator in both SQLite and PostgreSQL.
// The CASE expression conditionally adds a newline only when the row
// already has content, so the very first append doesn't get a leading \n.
//
// (audit medium #17) Many callers invoke this fire-and-forget (unawaited), so a
// rejected DB write would become an unhandled rejection and the log line would
// vanish with no trace. Swallow the write failure HERE so every caller — awaited
// or not — is uniformly protected: a failed append surfaces exactly one stderr
// line (with the execution id) and never rejects. Execution success/failure
// status is persisted separately via awaited create/updateScheduleExecution, so
// only the free-text detail log is affected.
try {
await db.update(scheduleExecutions)
.set({
logs: sql`CASE WHEN ${scheduleExecutions.logs} IS NULL OR ${scheduleExecutions.logs} = '' THEN ${logLine} ELSE ${scheduleExecutions.logs} || ${'\n' + logLine} END`
})
.where(eq(scheduleExecutions.id, id));
} catch (e) {
console.error('[scheduler] failed to persist execution log line', { executionId: id, error: e });
}
}
export async function getScheduleExecution(id: number): Promise<ScheduleExecutionData | undefined> {
const results = await db.select().from(scheduleExecutions).where(eq(scheduleExecutions.id, id));
if (!results[0]) return undefined;
return {
...results[0],
details: results[0].details ? JSON.parse(results[0].details) : null
} as ScheduleExecutionData;
}
export async function deleteScheduleExecution(id: number): Promise<void> {
await db.delete(scheduleExecutions).where(eq(scheduleExecutions.id, id));
}
export async function getScheduleExecutions(filters: ScheduleExecutionFilters = {}): Promise<ScheduleExecutionResult> {
const conditions: any[] = [];
if (filters.scheduleType) {
conditions.push(eq(scheduleExecutions.scheduleType, filters.scheduleType));
}
if (filters.scheduleId !== undefined) {
conditions.push(eq(scheduleExecutions.scheduleId, filters.scheduleId));
}
if (filters.environmentId !== undefined) {
if (filters.environmentId === null) {
conditions.push(isNull(scheduleExecutions.environmentId));
} else {
conditions.push(eq(scheduleExecutions.environmentId, filters.environmentId));
}
}
if (filters.entityName !== undefined) {
conditions.push(eq(scheduleExecutions.entityName, filters.entityName));
}
if (filters.status) {
conditions.push(eq(scheduleExecutions.status, filters.status));
}
if (filters.statuses && filters.statuses.length > 0) {
conditions.push(inArray(scheduleExecutions.status, filters.statuses));
}
if (filters.triggeredBy) {
conditions.push(eq(scheduleExecutions.triggeredBy, filters.triggeredBy));
}
if (filters.fromDate) {
conditions.push(sql`triggered_at >= ${filters.fromDate}`);
}
if (filters.toDate) {
conditions.push(sql`triggered_at <= ${filters.toDate}`);
}
const limit = filters.limit || 50;
const offset = filters.offset || 0;
// Get total count
const countResult = await db
.select({ count: sql<number>`count(*)` })
.from(scheduleExecutions)
.where(conditions.length > 0 ? and(...conditions) : undefined);
const total = Number(countResult[0]?.count || 0);
// Get paginated results (without full logs for list view)
const results = await db
.select({
id: scheduleExecutions.id,
scheduleType: scheduleExecutions.scheduleType,
scheduleId: scheduleExecutions.scheduleId,
environmentId: scheduleExecutions.environmentId,
entityName: scheduleExecutions.entityName,
triggeredBy: scheduleExecutions.triggeredBy,
triggeredAt: scheduleExecutions.triggeredAt,
startedAt: scheduleExecutions.startedAt,
completedAt: scheduleExecutions.completedAt,
duration: scheduleExecutions.duration,
status: scheduleExecutions.status,
errorMessage: scheduleExecutions.errorMessage,
details: scheduleExecutions.details,
createdAt: scheduleExecutions.createdAt
})
.from(scheduleExecutions)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(scheduleExecutions.triggeredAt))
.limit(limit)
.offset(offset);
return {
executions: results.map(row => ({
...row,
details: row.details ? JSON.parse(row.details) : null,
logs: null // Don't include logs in list view
})) as ScheduleExecutionData[],
total,
limit,
offset
};
}
/**
* ALL execution ids (+ parsed details) for a given schedule type, unpaginated.
*
* Deliberately bypasses getScheduleExecutions()'s default 50-row page: a caller that
* needs to reconcile every record against something else (deploy-log-reconcile.ts,
* against files on disk) cannot afford to only see the most recent page -- an older
* record just outside the window would look exactly like an orphan file with no
* record, and the file behind it would be deleted even though a record exists.
*
* `environmentId` is included (F5 fix) so the caller can scope its reconciliation
* PER ENVIRONMENT -- deploy-log-store.ts now keeps one log directory per environment
* (see envDirName()), so a record's file can only ever be found in, or be deleted
* from, ITS OWN environment's directory. Without environmentId here,
* deploy-log-reconcile.ts would have to either reconcile everything as one flat pool
* again (reintroducing the cross-environment mixing the size-budget fix closes) or
* guess which environment a record belongs to.
*/
export async function getScheduleExecutionIdsByType(
scheduleType: ScheduleType
): Promise<Array<{ id: number; status: ScheduleStatus; details: any | null; environmentId: number | null }>> {
const rows = await db
.select({
id: scheduleExecutions.id,
status: scheduleExecutions.status,
details: scheduleExecutions.details,
environmentId: scheduleExecutions.environmentId
})
.from(scheduleExecutions)
.where(eq(scheduleExecutions.scheduleType, scheduleType));
return rows.map((row: { id: number; status: string; details: string | null; environmentId: number | null }) => ({
id: row.id,
status: row.status as ScheduleStatus,
details: row.details ? JSON.parse(row.details) : null,
environmentId: row.environmentId
}));
}
/**
* Scheduled-task health for the metrics endpoint: execution counts by
* (scheduleType, status), and per-type age (seconds) of the last run and last
* SUCCESSFUL run. The last-run age detects a scheduler that stopped firing; the
* last-success age detects one that fires but keeps failing. Best-effort.
*/
export async function getScheduleStats(): Promise<{
byTypeStatus: Array<{ type: string; status: string; count: number }>;
lastRunSecondsByType: Record<string, number>;
lastSuccessSecondsByType: Record<string, number>;
}> {
const counts = await db
.select({
type: scheduleExecutions.scheduleType,
status: scheduleExecutions.status,
count: sql<number>`count(*)`
})
.from(scheduleExecutions)
.groupBy(scheduleExecutions.scheduleType, scheduleExecutions.status);
// Most-recent triggeredAt per type (any status) and per type for successes.
const lastAny = await db
.select({ type: scheduleExecutions.scheduleType, ts: sql<string>`max(${scheduleExecutions.triggeredAt})` })
.from(scheduleExecutions)
.groupBy(scheduleExecutions.scheduleType);
const lastOk = await db
.select({ type: scheduleExecutions.scheduleType, ts: sql<string>`max(${scheduleExecutions.triggeredAt})` })
.from(scheduleExecutions)
.where(eq(scheduleExecutions.status, 'success'))
.groupBy(scheduleExecutions.scheduleType);
const now = Date.now();
const ageMap = (rows: Array<{ type: string; ts: string | null }>): Record<string, number> => {
const out: Record<string, number> = {};
for (const r of rows) {
if (!r.ts) continue;
const t = new Date(r.ts).getTime();
if (!Number.isNaN(t)) out[r.type] = Math.max(0, Math.round((now - t) / 1000));
}
return out;
};
return {
byTypeStatus: counts.map((c) => ({ type: c.type, status: c.status, count: Number(c.count) })),
lastRunSecondsByType: ageMap(lastAny),
lastSuccessSecondsByType: ageMap(lastOk)
};
}
/**
* Per-backup-config health for the metrics endpoint (audit medium #16). The
* type-level schedule gauges can't isolate a single silently-failing backup
* config; this surfaces per-config last-success age and last status so one broken
* target is alertable. Cardinality is bounded by the number of backup configs.
* `lastSuccessSeconds` is left null when the config has no SUCCESSFUL backup so a
* never-succeeded config is visibly absent rather than defaulting to green.
*/
export async function getBackupConfigMetrics(): Promise<Array<{
configId: number;
target: string;
envId: number | null;
lastSuccessSeconds: number | null;
status: string | null;
}>> {
const rows = await db
.select({
id: backupConfigs.id,
target: backupConfigs.targetName,
envId: backupConfigs.environmentId,
lastBackupAt: backupConfigs.lastBackupAt,
lastBackupStatus: backupConfigs.lastBackupStatus
})
.from(backupConfigs);
const now = Date.now();
return rows.map((r) => {
let lastSuccessSeconds: number | null = null;
if (r.lastBackupStatus === 'success' && r.lastBackupAt) {
const t = new Date(r.lastBackupAt).getTime();
if (!Number.isNaN(t)) lastSuccessSeconds = Math.max(0, Math.round((now - t) / 1000));
}
return {
configId: r.id,
target: r.target,
envId: r.envId ?? null,
lastSuccessSeconds,
status: r.lastBackupStatus ?? null
};
});
}
export async function getLastExecutionForSchedule(
scheduleType: ScheduleType,
scheduleId: number
): Promise<ScheduleExecutionData | undefined> {
const results = await db
.select()
.from(scheduleExecutions)
.where(and(
eq(scheduleExecutions.scheduleType, scheduleType),
eq(scheduleExecutions.scheduleId, scheduleId)
))
.orderBy(desc(scheduleExecutions.triggeredAt))
.limit(1);
if (!results[0]) return undefined;
return {
...results[0],
details: results[0].details ? JSON.parse(results[0].details) : null
} as ScheduleExecutionData;
}
export async function getRecentExecutionsForSchedule(
scheduleType: ScheduleType,
scheduleId: number,
limit = 5
): Promise<ScheduleExecutionData[]> {
const results = await db
.select({
id: scheduleExecutions.id,
scheduleType: scheduleExecutions.scheduleType,
scheduleId: scheduleExecutions.scheduleId,
environmentId: scheduleExecutions.environmentId,
entityName: scheduleExecutions.entityName,
triggeredBy: scheduleExecutions.triggeredBy,
triggeredAt: scheduleExecutions.triggeredAt,
startedAt: scheduleExecutions.startedAt,
completedAt: scheduleExecutions.completedAt,
duration: scheduleExecutions.duration,
status: scheduleExecutions.status,
errorMessage: scheduleExecutions.errorMessage,
details: scheduleExecutions.details,
createdAt: scheduleExecutions.createdAt
})
.from(scheduleExecutions)
.where(and(
eq(scheduleExecutions.scheduleType, scheduleType),
eq(scheduleExecutions.scheduleId, scheduleId)
))
.orderBy(desc(scheduleExecutions.triggeredAt))
.limit(limit);
return results.map(row => ({
...row,
details: row.details ? JSON.parse(row.details) : null,
logs: null
})) as ScheduleExecutionData[];
}
export async function cleanupOldExecutions(retentionDays: number): Promise<number> {
const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`);
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`);
}
return count;
}
// Settings helpers for retention
const SCHEDULE_RETENTION_KEY = 'schedule_retention_days';
const EVENT_RETENTION_KEY = 'event_retention_days';
const DEFAULT_RETENTION_DAYS = 30;
const SCHEDULE_CLEANUP_CRON_KEY = 'schedule_cleanup_cron';
const EVENT_CLEANUP_CRON_KEY = 'event_cleanup_cron';
const SCHEDULE_CLEANUP_ENABLED_KEY = 'schedule_cleanup_enabled';
const EVENT_CLEANUP_ENABLED_KEY = 'event_cleanup_enabled';
const SCANNER_CLEANUP_CRON_KEY = 'scanner_cleanup_cron';
const SCANNER_CLEANUP_ENABLED_KEY = 'scanner_cleanup_enabled';
const DEPLOY_LOG_RECONCILE_CRON_KEY = 'deploy_log_reconcile_cron';
const DEPLOY_LOG_RECONCILE_ENABLED_KEY = 'deploy_log_reconcile_enabled';
const DEFAULT_SCHEDULE_CLEANUP_CRON = '0 3 * * *'; // Daily at 3 AM
const DEFAULT_EVENT_CLEANUP_CRON = '30 3 * * *'; // Daily at 3:30 AM
const DEFAULT_SCANNER_CLEANUP_CRON = '0 3 * * 0'; // Weekly Sunday at 3 AM
const DEFAULT_DEPLOY_LOG_RECONCILE_CRON = '0 4 * * *'; // Daily at 4 AM
export async function getScheduleRetentionDays(): Promise<number> {
const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY));
if (result[0]) {
return parseInt(result[0].value, 10) || DEFAULT_RETENTION_DAYS;
}
return DEFAULT_RETENTION_DAYS;
}
export async function setScheduleRetentionDays(days: number): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: String(days), updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCHEDULE_RETENTION_KEY));
} else {
await db.insert(settings).values({
key: SCHEDULE_RETENTION_KEY,
value: String(days)
});
}
}
export async function getEventRetentionDays(): Promise<number> {
const result = await db.select().from(settings).where(eq(settings.key, EVENT_RETENTION_KEY));
if (result[0]) {
return parseInt(result[0].value, 10) || DEFAULT_RETENTION_DAYS;
}
return DEFAULT_RETENTION_DAYS;
}
export async function setEventRetentionDays(days: number): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, EVENT_RETENTION_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: String(days), updatedAt: new Date().toISOString() })
.where(eq(settings.key, EVENT_RETENTION_KEY));
} else {
await db.insert(settings).values({
key: EVENT_RETENTION_KEY,
value: String(days)
});
}
}
export async function getScheduleCleanupCron(): Promise<string> {
const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_CLEANUP_CRON_KEY));
if (result[0]) {
return result[0].value || DEFAULT_SCHEDULE_CLEANUP_CRON;
}
return DEFAULT_SCHEDULE_CLEANUP_CRON;
}
export async function setScheduleCleanupCron(cron: string): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCHEDULE_CLEANUP_CRON_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: cron, updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCHEDULE_CLEANUP_CRON_KEY));
} else {
await db.insert(settings).values({
key: SCHEDULE_CLEANUP_CRON_KEY,
value: cron
});
}
}
export async function getEventCleanupCron(): Promise<string> {
const result = await db.select().from(settings).where(eq(settings.key, EVENT_CLEANUP_CRON_KEY));
if (result[0]) {
return result[0].value || DEFAULT_EVENT_CLEANUP_CRON;
}
return DEFAULT_EVENT_CLEANUP_CRON;
}
export async function setEventCleanupCron(cron: string): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, EVENT_CLEANUP_CRON_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: cron, updatedAt: new Date().toISOString() })
.where(eq(settings.key, EVENT_CLEANUP_CRON_KEY));
} else {
await db.insert(settings).values({
key: EVENT_CLEANUP_CRON_KEY,
value: cron
});
}
}
export async function getScheduleCleanupEnabled(): Promise<boolean> {
const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_CLEANUP_ENABLED_KEY));
if (result[0]) {
return result[0].value === 'true';
}
return true; // Enabled by default
}
export async function setScheduleCleanupEnabled(enabled: boolean): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCHEDULE_CLEANUP_ENABLED_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCHEDULE_CLEANUP_ENABLED_KEY));
} else {
await db.insert(settings).values({
key: SCHEDULE_CLEANUP_ENABLED_KEY,
value: enabled ? 'true' : 'false'
});
}
}
export async function getEventCleanupEnabled(): Promise<boolean> {
const result = await db.select().from(settings).where(eq(settings.key, EVENT_CLEANUP_ENABLED_KEY));
if (result[0]) {
return result[0].value === 'true';
}
return true; // Enabled by default
}
export async function setEventCleanupEnabled(enabled: boolean): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, EVENT_CLEANUP_ENABLED_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() })
.where(eq(settings.key, EVENT_CLEANUP_ENABLED_KEY));
} else {
await db.insert(settings).values({
key: EVENT_CLEANUP_ENABLED_KEY,
value: enabled ? 'true' : 'false'
});
}
}
export async function getScannerCleanupCron(): Promise<string> {
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
if (result[0]) {
return result[0].value || DEFAULT_SCANNER_CLEANUP_CRON;
}
return DEFAULT_SCANNER_CLEANUP_CRON;
}
export async function setScannerCleanupCron(cron: string): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: cron, updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
} else {
await db.insert(settings).values({
key: SCANNER_CLEANUP_CRON_KEY,
value: cron
});
}
}
export async function getScannerCleanupEnabled(): Promise<boolean> {
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
if (result[0]) {
return result[0].value === 'true';
}
return true; // Enabled by default
}
export async function setScannerCleanupEnabled(enabled: boolean): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
} else {
await db.insert(settings).values({
key: SCANNER_CLEANUP_ENABLED_KEY,
value: enabled ? 'true' : 'false'
});
}
}
export async function getDeployLogReconcileCron(): Promise<string> {
const result = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_CRON_KEY));
if (result[0]) {
return result[0].value || DEFAULT_DEPLOY_LOG_RECONCILE_CRON;
}
return DEFAULT_DEPLOY_LOG_RECONCILE_CRON;
}
export async function setDeployLogReconcileCron(cron: string): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_CRON_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: cron, updatedAt: new Date().toISOString() })
.where(eq(settings.key, DEPLOY_LOG_RECONCILE_CRON_KEY));
} else {
await db.insert(settings).values({
key: DEPLOY_LOG_RECONCILE_CRON_KEY,
value: cron
});
}
}
export async function getDeployLogReconcileEnabled(): Promise<boolean> {
const result = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_ENABLED_KEY));
if (result[0]) {
return result[0].value === 'true';
}
return true; // Enabled by default
}
export async function setDeployLogReconcileEnabled(enabled: boolean): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_ENABLED_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() })
.where(eq(settings.key, DEPLOY_LOG_RECONCILE_ENABLED_KEY));
} else {
await db.insert(settings).values({
key: DEPLOY_LOG_RECONCILE_ENABLED_KEY,
value: enabled ? 'true' : 'false'
});
}
}
// =============================================================================
// EXTERNAL STACK PATHS
// =============================================================================
const EXTERNAL_STACK_PATHS_KEY = 'external_stack_paths';
export async function getExternalStackPaths(): Promise<string[]> {
const result = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
if (result[0]) {
try {
const parsed = JSON.parse(result[0].value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
}
export async function setExternalStackPaths(paths: string[]): Promise<void> {
const jsonValue = JSON.stringify(paths);
const existing = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: jsonValue, updatedAt: new Date().toISOString() })
.where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
} else {
await db.insert(settings).values({
key: EXTERNAL_STACK_PATHS_KEY,
value: jsonValue
});
}
}
/**
* Idempotently add a directory to the external stack paths allowlist.
* Returns true if the path was newly added (false if already present).
*/
export async function addExternalStackPath(dir: string): Promise<boolean> {
const current = await getExternalStackPaths();
if (current.includes(dir)) return false;
await setExternalStackPaths([...current, dir]);
return true;
}
// =============================================================================
// PRIMARY STACK LOCATION
// =============================================================================
const PRIMARY_STACK_LOCATION_KEY = 'primary_stack_location';
export async function getPrimaryStackLocation(): Promise<string | null> {
const result = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
if (result[0]?.value) {
return result[0].value;
}
return null;
}
export async function setPrimaryStackLocation(path: string | null): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
if (path === null) {
// Delete the setting if path is null
if (existing.length > 0) {
await db.delete(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
}
} else if (existing.length > 0) {
await db.update(settings)
.set({ value: path, updatedAt: new Date().toISOString() })
.where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
} else {
await db.insert(settings).values({
key: PRIMARY_STACK_LOCATION_KEY,
value: path
});
}
}
// =============================================================================
// ENVIRONMENT UPDATE CHECK SETTINGS
// =============================================================================
export interface EnvUpdateCheckSettings {
enabled: boolean;
cron: string;
autoUpdate: boolean;
vulnerabilityCriteria: VulnerabilityCriteria;
}
/**
* Global newer-version-tag (semver) detection config. Per-env settings decide
* WHEN/whether to check on a schedule; this decides HOW versions are read, and
* applies to every check - scheduled and manual alike.
*/
export interface GlobalSemverConfig {
enabled: boolean;
maxBump: 'patch' | 'minor' | 'major';
matchFlavor: boolean;
includePrerelease: boolean;
}
const GLOBAL_SEMVER_KEY = 'global_semver_check';
const DEFAULT_SEMVER_CONFIG: GlobalSemverConfig = {
enabled: false,
maxBump: 'major',
matchFlavor: true,
includePrerelease: false
};
export async function getGlobalSemverConfig(): Promise<GlobalSemverConfig> {
const result = await db.select().from(settings).where(eq(settings.key, GLOBAL_SEMVER_KEY));
if (!result[0]) return { ...DEFAULT_SEMVER_CONFIG };
try {
return { ...DEFAULT_SEMVER_CONFIG, ...JSON.parse(result[0].value) };
} catch {
return { ...DEFAULT_SEMVER_CONFIG };
}
}
export async function setGlobalSemverConfig(config: GlobalSemverConfig): Promise<void> {
const value = JSON.stringify(config);
const existing = await db.select().from(settings).where(eq(settings.key, GLOBAL_SEMVER_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value, updatedAt: new Date().toISOString() })
.where(eq(settings.key, GLOBAL_SEMVER_KEY));
} else {
await db.insert(settings).values({ key: GLOBAL_SEMVER_KEY, value });
}
}
export async function getEnvUpdateCheckSettings(envId: number): Promise<EnvUpdateCheckSettings | null> {
const key = `env_${envId}_update_check`;
const result = await db.select().from(settings).where(eq(settings.key, key));
if (!result[0]) return null;
try {
return JSON.parse(result[0].value);
} catch {
return null;
}
}
export async function setEnvUpdateCheckSettings(envId: number, config: EnvUpdateCheckSettings): Promise<void> {
const key = `env_${envId}_update_check`;
const value = JSON.stringify(config);
const existing = await db.select().from(settings).where(eq(settings.key, key));
if (existing.length > 0) {
await db.update(settings)
.set({ value, updatedAt: new Date().toISOString() })
.where(eq(settings.key, key));
} else {
await db.insert(settings).values({ key, value });
}
}
export async function deleteEnvUpdateCheckSettings(envId: number): Promise<void> {
const key = `env_${envId}_update_check`;
await db.delete(settings).where(eq(settings.key, key));
}
export async function getAllEnvUpdateCheckSettings(): Promise<Array<{ envId: number; settings: EnvUpdateCheckSettings }>> {
const rows = await db.select().from(settings).where(sql`${settings.key} LIKE 'env_%_update_check'`);
const results: Array<{ envId: number; settings: EnvUpdateCheckSettings }> = [];
for (const row of rows) {
try {
const match = row.key.match(/^env_(\d+)_update_check$/);
if (!match) continue;
const envId = parseInt(match[1]);
const config = JSON.parse(row.value) as EnvUpdateCheckSettings;
if (config.enabled) {
results.push({ envId, settings: config });
}
} catch {
// Skip invalid entries
}
}
return results;
}
// =============================================================================
// IMAGE PRUNE SCHEDULE SETTINGS
// =============================================================================
export interface ImagePruneSettings {
enabled: boolean;
cronExpression: string;
pruneMode: 'dangling' | 'all';
lastPruned?: string;
lastResult?: {
spaceReclaimed: number;
imagesRemoved: number;
};
}
export async function getImagePruneSettings(envId: number): Promise<ImagePruneSettings | null> {
const key = `env_${envId}_image_prune`;
const result = await db.select().from(settings).where(eq(settings.key, key));
if (!result[0]) return null;
try {
return JSON.parse(result[0].value);
} catch {
return null;
}
}
export async function setImagePruneSettings(envId: number, config: ImagePruneSettings): Promise<void> {
const key = `env_${envId}_image_prune`;
const value = JSON.stringify(config);
const existing = await db.select().from(settings).where(eq(settings.key, key));
if (existing.length > 0) {
await db.update(settings)
.set({ value, updatedAt: new Date().toISOString() })
.where(eq(settings.key, key));
} else {
await db.insert(settings).values({ key, value });
}
}
export async function deleteImagePruneSettings(envId: number): Promise<void> {
const key = `env_${envId}_image_prune`;
await db.delete(settings).where(eq(settings.key, key));
}
export async function getAllImagePruneSettings(): Promise<Array<{ envId: number; settings: ImagePruneSettings }>> {
const rows = await db.select().from(settings).where(sql`${settings.key} LIKE 'env_%_image_prune'`);
const results: Array<{ envId: number; settings: ImagePruneSettings }> = [];
for (const row of rows) {
try {
const match = row.key.match(/^env_(\d+)_image_prune$/);
if (!match) continue;
const envId = parseInt(match[1]);
const config = JSON.parse(row.value) as ImagePruneSettings;
// Return all settings, not just enabled ones (UI needs to show disabled schedules too)
results.push({ envId, settings: config });
} catch {
// Skip invalid entries
}
}
return results;
}
// =============================================================================
// ENVIRONMENT TIMEZONE SETTINGS
// =============================================================================
export async function getEnvironmentTimezone(envId: number): Promise<string> {
const value = await getSetting(`env_${envId}_timezone`);
return value || 'UTC';
}
export async function setEnvironmentTimezone(envId: number, timezone: string): Promise<void> {
await setSetting(`env_${envId}_timezone`, timezone);
}
// =============================================================================
// GLOBAL DEFAULT TIMEZONE
// =============================================================================
/**
* Get the global default timezone (used as default for new environments).
* Falls back to 'UTC' if not set.
*/
export async function getDefaultTimezone(): Promise<string> {
const value = await getSetting('default_timezone');
return value || 'UTC';
}
/**
* Set the global default timezone.
*/
export async function setDefaultTimezone(timezone: string): Promise<void> {
await setSetting('default_timezone', timezone);
}
// =============================================================================
// BACKGROUND MONITORING SETTINGS
// =============================================================================
/**
* Get event collection mode ('stream' or 'poll').
* Defaults to 'stream' for real-time event streaming.
*/
export async function getEventCollectionMode(): Promise<'stream' | 'poll'> {
const value = await getSetting('event_collection_mode');
return value || 'stream';
}
/**
* Set event collection mode.
*/
export async function setEventCollectionMode(mode: 'stream' | 'poll'): Promise<void> {
await setSetting('event_collection_mode', mode);
}
/**
* Get event poll interval in milliseconds.
* Defaults to 60000ms (60 seconds).
*/
export async function getEventPollInterval(): Promise<number> {
const value = await getSetting('event_poll_interval');
return value || 60000;
}
/**
* Set event poll interval in milliseconds.
* Valid range: 30000ms (30s) to 300000ms (5min).
*/
export async function setEventPollInterval(interval: number): Promise<void> {
if (interval < 30000 || interval > 300000) {
throw new Error('Event poll interval must be between 30s and 300s');
}
await setSetting('event_poll_interval', interval);
}
/**
* Get metrics collection interval in milliseconds.
* Defaults to 30000ms (30 seconds) - changed from hardcoded 10s.
*/
export async function getMetricsCollectionInterval(): Promise<number> {
const value = await getSetting('metrics_collection_interval');
return value || 30000;
}
/**
* Set metrics collection interval in milliseconds.
* Valid range: 10000ms (10s) to 300000ms (5min).
*/
export async function setMetricsCollectionInterval(interval: number): Promise<void> {
if (interval < 10000 || interval > 300000) {
throw new Error('Metrics collection interval must be between 10s and 300s');
}
await setSetting('metrics_collection_interval', interval);
}
// =============================================================================
// STACK ENVIRONMENT VARIABLES OPERATIONS
// =============================================================================
export interface StackEnvVarData {
id: number;
stackName: string;
environmentId: number | null;
key: string;
value: string;
isSecret: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Get all environment variables for a stack.
* @param stackName - Name of the stack
* @param environmentId - Optional environment ID to filter by
* @param maskSecrets - If true, masks secret values with '***' (default: true)
*/
export async function getStackEnvVars(
stackName: string,
environmentId?: number | null,
maskSecrets: boolean = true
): Promise<StackEnvVarData[]> {
let results;
if (environmentId !== undefined) {
if (environmentId === null) {
results = await db.select().from(stackEnvironmentVariables)
.where(and(
eq(stackEnvironmentVariables.stackName, stackName),
isNull(stackEnvironmentVariables.environmentId)
))
.orderBy(asc(stackEnvironmentVariables.key));
} else {
results = await db.select().from(stackEnvironmentVariables)
.where(and(
eq(stackEnvironmentVariables.stackName, stackName),
eq(stackEnvironmentVariables.environmentId, environmentId)
))
.orderBy(asc(stackEnvironmentVariables.key));
}
} else {
results = await db.select().from(stackEnvironmentVariables)
.where(eq(stackEnvironmentVariables.stackName, stackName))
.orderBy(asc(stackEnvironmentVariables.key));
}
return results.map(row => {
// Decrypt secret values (decrypt handles both encrypted and plain text)
const decryptedValue = row.isSecret ? (decrypt(row.value) ?? '') : row.value;
return {
id: row.id,
stackName: row.stackName,
environmentId: row.environmentId,
key: row.key,
value: maskSecrets && row.isSecret ? '***' : decryptedValue,
isSecret: row.isSecret ?? false,
createdAt: row.createdAt ?? new Date().toISOString(),
updatedAt: row.updatedAt ?? new Date().toISOString()
};
});
}
/**
* Get stack environment variables as a key-value record (for deployment).
* Does NOT mask secrets - returns raw values for use in Docker deployment.
* @param stackName - Name of the stack
* @param environmentId - Optional environment ID
*/
export async function getStackEnvVarsAsRecord(
stackName: string,
environmentId?: number | null
): Promise<Record<string, string>> {
const vars = await getStackEnvVars(stackName, environmentId, false);
return Object.fromEntries(vars.map(v => [v.key, v.value]));
}
/**
* Get only SECRET environment variables as a key-value record (for shell injection).
* Returns unmasked real values - used to inject secrets via shell environment at runtime.
* These secrets are NEVER written to .env files on disk.
* @param stackName - Name of the stack
* @param environmentId - Optional environment ID
*/
export async function getSecretEnvVarsAsRecord(
stackName: string,
environmentId?: number | null
): Promise<Record<string, string>> {
const vars = await getStackEnvVars(stackName, environmentId, false);
return Object.fromEntries(
vars.filter(v => v.isSecret).map(v => [v.key, v.value])
);
}
/**
* Get a stack's SECRET env vars as their RAW at-rest ciphertext (enc:v1:...), keyed
* by name. Unlike the *AsRecord helpers this does NOT decrypt: it returns the exact
* bytes stored in the DB. Used by backup to carry secrets in a snapshot still
* encrypted under this instance's key — never plaintext. A value that is somehow
* stored plaintext (legacy) is encrypted here so a snapshot never holds a bare secret.
*/
export async function getStackSecretCiphertexts(
stackName: string,
environmentId?: number | null
): Promise<Array<{ key: string; value: string }>> {
let rows;
if (environmentId === undefined) {
rows = await db.select().from(stackEnvironmentVariables)
.where(eq(stackEnvironmentVariables.stackName, stackName));
} else if (environmentId === null) {
rows = await db.select().from(stackEnvironmentVariables)
.where(and(
eq(stackEnvironmentVariables.stackName, stackName),
isNull(stackEnvironmentVariables.environmentId)
));
} else {
rows = await db.select().from(stackEnvironmentVariables)
.where(and(
eq(stackEnvironmentVariables.stackName, stackName),
eq(stackEnvironmentVariables.environmentId, environmentId)
));
}
return rows
.filter((r: typeof rows[number]) => r.isSecret && r.value)
.map((r: typeof rows[number]) => ({ key: r.key, value: isEncrypted(r.value) ? r.value : (encrypt(r.value) ?? '') }))
.filter((r: { key: string; value: string }) => r.value);
}
/**
* Get only NON-SECRET environment variables as a key-value record.
* Used for .env file operations where secrets should be excluded.
* @param stackName - Name of the stack
* @param environmentId - Optional environment ID
*/
export async function getNonSecretEnvVarsAsRecord(
stackName: string,
environmentId?: number | null
): Promise<Record<string, string>> {
const vars = await getStackEnvVars(stackName, environmentId, false);
return Object.fromEntries(
vars.filter(v => !v.isSecret).map(v => [v.key, v.value])
);
}
/**
* Set/replace all environment variables for a stack.
* Deletes existing vars and inserts new ones in a transaction-like manner.
* @param stackName - Name of the stack
* @param environmentId - Optional environment ID
* @param variables - Array of {key, value, isSecret} objects
*/
export async function setStackEnvVars(
stackName: string,
environmentId: number | null,
variables: Array<{ key: string; value: string; isSecret?: boolean }>
): Promise<void> {
// Delete existing vars for this stack/environment combo
if (environmentId === null) {
await db.delete(stackEnvironmentVariables)
.where(and(
eq(stackEnvironmentVariables.stackName, stackName),
isNull(stackEnvironmentVariables.environmentId)
));
} else {
await db.delete(stackEnvironmentVariables)
.where(and(
eq(stackEnvironmentVariables.stackName, stackName),
eq(stackEnvironmentVariables.environmentId, environmentId)
));
}
// Insert new vars (deduplicate by key - last entry wins)
if (variables.length > 0) {
const seen = new Map<string, { key: string; value: string; isSecret?: boolean }>();
for (const v of variables) {
seen.set(v.key, v);
}
const deduped = Array.from(seen.values());
const now = new Date().toISOString();
await db.insert(stackEnvironmentVariables).values(
deduped.map(v => ({
stackName,
environmentId,
key: v.key,
// Encrypt values that are marked as secrets
value: v.isSecret ? (encrypt(v.value) ?? '') : v.value,
isSecret: v.isSecret ?? false,
createdAt: now,
updatedAt: now
}))
);
}
}
/**
* Get the set of secret key names for a stack.
* Used to mask secret values in container inspect responses.
*/
export async function getSecretKeyNames(
stackName: string,
environmentId?: number | null
): Promise<Set<string>> {
const vars = await getStackEnvVars(stackName, environmentId, true);
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
}
/**
* Get the set of env var keys that should be masked in container inspect responses.
* Handles two cases:
* 1. Direct match: env var key == secret key in DB (e.g., DB_PASS=${DB_PASS})
* 2. Interpolation: env var key differs from secret key (e.g., MYSQL_PASSWORD=${db_secret})
* Detected by parsing the compose file for ${variable} references in environment: sections.
*
* @param composeContent - Optional compose file content. If provided, interpolation
* references are parsed to detect secrets injected under different key names.
*/
export async function getSecretKeysToMask(
stackName: string,
environmentId?: number | null,
composeContent?: string | null
): Promise<Set<string>> {
const vars = await getStackEnvVars(stackName, environmentId, true);
const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key));
// Provider-injected secrets (Vault/Doppler bulk keys, promoted op:// refs) live
// only in the provider, never in stack_env_vars, so add the names persisted on
// the last deploy. Without this they leak plaintext in container inspect.
const injected = await getStackInjectedSecretKeys(stackName, environmentId);
for (const key of injected) secretKeyNames.add(key);
if (secretKeyNames.size === 0) return secretKeyNames;
// If we have compose content, parse interpolation references to find
// container env keys that map to secret interpolation variables.
// e.g., "MYSQL_PASSWORD=${db_secret}" → if db_secret is a secret, mask MYSQL_PASSWORD too.
if (composeContent) {
const interpolated = parseEnvInterpolation(composeContent);
for (const [containerKey, varName] of interpolated) {
if (secretKeyNames.has(varName)) {
secretKeyNames.add(containerKey);
}
}
}
return secretKeyNames;
}
export { parseEnvInterpolation } from './env-interpolation';
/**
* Get count of environment variables for a stack.
* @param stackName - Name of the stack
* @param environmentId - Optional environment ID
*/
export async function getStackEnvVarsCount(
stackName: string,
environmentId?: number | null
): Promise<number> {
const vars = await getStackEnvVars(stackName, environmentId, false);
return vars.length;
}
/**
* Delete all environment variables for a stack.
* @param stackName - Name of the stack
* @param environmentId - Optional environment ID (null = delete for all envs)
*/
export async function deleteStackEnvVars(
stackName: string,
environmentId?: number | null
): Promise<void> {
if (environmentId === undefined) {
// Delete all env vars for this stack (all environments)
await db.delete(stackEnvironmentVariables)
.where(eq(stackEnvironmentVariables.stackName, stackName));
} else if (environmentId === null) {
await db.delete(stackEnvironmentVariables)
.where(and(
eq(stackEnvironmentVariables.stackName, stackName),
isNull(stackEnvironmentVariables.environmentId)
));
} else {
await db.delete(stackEnvironmentVariables)
.where(and(
eq(stackEnvironmentVariables.stackName, stackName),
eq(stackEnvironmentVariables.environmentId, environmentId)
));
}
}
/**
* Update stack name in environment variables (for stack rename operations).
* @param oldStackName - Current stack name
* @param newStackName - New stack name
* @param environmentId - Optional environment ID (null = no environment, undefined = all environments)
*/
export async function updateStackEnvVarsName(
oldStackName: string,
newStackName: string,
environmentId?: number | null
): Promise<void> {
if (environmentId === undefined) {
// Update all env vars for this stack (all environments)
await db.update(stackEnvironmentVariables)
.set({ stackName: newStackName })
.where(eq(stackEnvironmentVariables.stackName, oldStackName));
} else if (environmentId === null) {
await db.update(stackEnvironmentVariables)
.set({ stackName: newStackName })
.where(and(
eq(stackEnvironmentVariables.stackName, oldStackName),
isNull(stackEnvironmentVariables.environmentId)
));
} else {
await db.update(stackEnvironmentVariables)
.set({ stackName: newStackName })
.where(and(
eq(stackEnvironmentVariables.stackName, oldStackName),
eq(stackEnvironmentVariables.environmentId, environmentId)
));
}
}
/**
* Get all stacks with their environment variable counts.
* Useful for displaying env var badges in the stacks list.
*/
export async function getAllStacksEnvVarsCounts(): Promise<Map<string, number>> {
const results = await db.select({
stackName: stackEnvironmentVariables.stackName
}).from(stackEnvironmentVariables);
const counts = new Map<string, number>();
for (const row of results) {
counts.set(row.stackName, (counts.get(row.stackName) || 0) + 1);
}
return counts;
}
// =============================================================================
// PENDING CONTAINER UPDATES OPERATIONS
// =============================================================================
/**
* Get all pending container updates for an environment.
*/
export async function getPendingContainerUpdates(environmentId: number): Promise<PendingContainerUpdate[]> {
return await db.select().from(pendingContainerUpdates)
.where(eq(pendingContainerUpdates.environmentId, environmentId));
}
/**
* Clear all pending container updates for an environment.
* Called before checking for updates to ensure fresh state.
*/
export async function clearPendingContainerUpdates(environmentId: number): Promise<void> {
await db.delete(pendingContainerUpdates)
.where(eq(pendingContainerUpdates.environmentId, environmentId));
}
/**
* Add a pending container update.
* Uses upsert to avoid duplicates.
*/
export async function addPendingContainerUpdate(
environmentId: number,
containerId: string,
containerName: string,
currentImage: string,
// A row can exist for a digest update, a newer-version-tag (semver) suggestion,
// or both. Both flags default to the classic "digest update only" shape so
// existing callers keep working unchanged.
options: { hasImageUpdate?: boolean; newerVersion?: unknown | null } = {}
): Promise<void> {
const hasImageUpdate = options.hasImageUpdate ?? true;
const newerVersion = options.newerVersion != null ? JSON.stringify(options.newerVersion) : null;
const now = new Date().toISOString();
// Use insert with onConflictDoUpdate for upsert behavior
await db.insert(pendingContainerUpdates)
.values({
environmentId,
containerId,
containerName,
currentImage,
hasImageUpdate,
newerVersion,
checkedAt: now
})
.onConflictDoUpdate({
target: [pendingContainerUpdates.environmentId, pendingContainerUpdates.containerId],
set: {
containerName,
currentImage,
hasImageUpdate,
newerVersion,
checkedAt: now
}
});
}
/**
* Remove a pending container update (after the container is updated).
*/
export async function removePendingContainerUpdate(environmentId: number, containerId: string): Promise<void> {
await db.delete(pendingContainerUpdates)
.where(and(
eq(pendingContainerUpdates.environmentId, environmentId),
eq(pendingContainerUpdates.containerId, containerId)
));
}
// =============================================================================
// BACKUP DESTINATION OPERATIONS
// =============================================================================
export type { BackupDestination, BackupConfig };
export async function getBackupDestinations(): Promise<BackupDestination[]> {
return db.select().from(backupDestinations).orderBy(desc(backupDestinations.createdAt));
}
export async function getBackupDestination(id: number): Promise<BackupDestination | undefined> {
const results = await db.select().from(backupDestinations).where(eq(backupDestinations.id, id));
return results[0];
}
export async function createBackupDestination(data: {
name: string;
repository: string;
password: string;
envVars?: string | null;
flags?: string | null;
hostPath?: string | null;
policies?: string | null;
cacert?: string | null;
tlsClientCert?: string | null;
}): Promise<BackupDestination> {
const result = await db.insert(backupDestinations).values({
name: data.name,
repository: data.repository,
password: encrypt(data.password)!,
envVars: data.envVars ? encrypt(data.envVars) : null,
flags: data.flags ?? null,
hostPath: data.hostPath ?? null,
policies: data.policies ?? null,
cacert: data.cacert ? encrypt(data.cacert) : null,
tlsClientCert: data.tlsClientCert ? encrypt(data.tlsClientCert) : null
}).returning();
return result[0];
}
export async function updateBackupDestination(id: number, data: {
name?: string;
repository?: string;
password?: string;
envVars?: string | null;
flags?: string | null;
hostPath?: string | null;
policies?: string | null;
cacert?: string | null;
tlsClientCert?: string | null;
lastTestAt?: string | null;
lastTestStatus?: string | null;
lastTestError?: string | null;
}): Promise<BackupDestination | undefined> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.name !== undefined) updateData.name = data.name;
if (data.repository !== undefined) updateData.repository = data.repository;
// Only write the password when a non-empty value is supplied. A partial /
// metadata-only edit sends an empty or undefined password; writing encrypt('')
// there would clobber the stored secret (audit #39).
if (data.password) updateData.password = encrypt(data.password)!;
// Same guard for envVars: an empty string would null out stored cloud creds.
// (An explicit '{}' JSON string is truthy and still clears them intentionally.)
if (data.envVars) updateData.envVars = encrypt(data.envVars);
// Certs (optional): undefined = keep, '' = clear (user removed it), value = encrypt.
if (data.cacert !== undefined) updateData.cacert = data.cacert ? encrypt(data.cacert) : null;
if (data.tlsClientCert !== undefined) updateData.tlsClientCert = data.tlsClientCert ? encrypt(data.tlsClientCert) : null;
if (data.flags !== undefined) updateData.flags = data.flags;
if (data.hostPath !== undefined) updateData.hostPath = data.hostPath;
if (data.policies !== undefined) updateData.policies = data.policies;
if (data.lastTestAt !== undefined) updateData.lastTestAt = data.lastTestAt;
if (data.lastTestStatus !== undefined) updateData.lastTestStatus = data.lastTestStatus;
if (data.lastTestError !== undefined) updateData.lastTestError = data.lastTestError;
await db.update(backupDestinations).set(updateData).where(eq(backupDestinations.id, id));
return getBackupDestination(id);
}
export async function deleteBackupDestination(id: number): Promise<void> {
await db.delete(backupDestinations).where(eq(backupDestinations.id, id));
}
export async function updateBackupDestinationTestStatus(id: number, status: 'success' | 'failed' | 'needs_init', error?: string | null): Promise<void> {
await db.update(backupDestinations).set({
lastTestAt: new Date().toISOString(),
lastTestStatus: status,
lastTestError: error ?? null,
updatedAt: new Date().toISOString()
}).where(eq(backupDestinations.id, id));
}
/**
* Decrypt sensitive fields from a backup destination for runtime use.
*/
export function decryptBackupDestination(dest: BackupDestination): BackupDestination & { decryptedPassword: string; decryptedEnvVars: Record<string, string>; decryptedCacert: string | null; decryptedTlsClientCert: string | null } {
// (audit low #55) Fail closed: if the stored password is a genuine ciphertext
// blob that can't be decrypted (wrong/rotated key), decryptStrict throws rather
// than forwarding the literal `enc:v1:...` string as RESTIC_PASSWORD.
const decryptedPassword = decryptStrict(dest.password) || '';
let decryptedEnvVars: Record<string, string> = {};
if (dest.envVars) {
const raw = decryptStrict(dest.envVars); // throws if envVars can't be decrypted
if (raw) {
try {
decryptedEnvVars = JSON.parse(raw);
} catch {
// decrypted OK but not valid JSON — leave envVars empty.
}
}
}
// PEM certs for a self-signed TLS backend (#1451). Same fail-closed rule as the
// password: a genuine ciphertext that can't be decrypted throws rather than passing
// the literal enc:v1 string to restic.
const decryptedCacert = dest.cacert ? (decryptStrict(dest.cacert) || null) : null;
const decryptedTlsClientCert = dest.tlsClientCert ? (decryptStrict(dest.tlsClientCert) || null) : null;
return { ...dest, decryptedPassword, decryptedEnvVars, decryptedCacert, decryptedTlsClientCert };
}
// =============================================================================
// BACKUP CONFIG OPERATIONS
// =============================================================================
export async function getBackupConfigs(filters?: {
type?: string;
targetName?: string;
environmentId?: number;
}): Promise<BackupConfig[]> {
const conditions: any[] = [];
if (filters?.type) conditions.push(eq(backupConfigs.type, filters.type));
if (filters?.targetName) conditions.push(eq(backupConfigs.targetName, filters.targetName));
if (filters?.environmentId !== undefined) conditions.push(eq(backupConfigs.environmentId, filters.environmentId));
const query = conditions.length > 0
? db.select().from(backupConfigs).where(and(...conditions))
: db.select().from(backupConfigs);
return query.orderBy(desc(backupConfigs.createdAt));
}
export async function getBackupConfig(id: number): Promise<BackupConfig | undefined> {
const results = await db.select().from(backupConfigs).where(eq(backupConfigs.id, id));
return results[0];
}
export async function getEnabledBackupConfigs(): Promise<BackupConfig[]> {
return db.select().from(backupConfigs).where(eq(backupConfigs.enabled, true));
}
export async function createBackupConfig(data: {
type?: string;
targetName: string;
environmentId?: number | null;
destinationId: number;
enabled?: boolean;
allVolumes?: boolean;
selectedVolumes?: string | null;
stopBeforeBackup?: boolean;
schedule?: string | null;
retention?: string | null;
options?: string | null;
tags?: string | null;
}): Promise<BackupConfig> {
const result = await db.insert(backupConfigs).values({
type: data.type || 'container',
targetName: data.targetName,
environmentId: data.environmentId ?? null,
destinationId: data.destinationId,
enabled: data.enabled ?? true,
allVolumes: data.allVolumes ?? true,
selectedVolumes: data.selectedVolumes ?? null,
stopBeforeBackup: data.stopBeforeBackup ?? false,
schedule: data.schedule ?? null,
retention: data.retention ?? null,
options: data.options ?? null,
tags: data.tags ?? null
}).returning();
return result[0];
}
export async function updateBackupConfig(id: number, data: {
destinationId?: number;
enabled?: boolean;
allVolumes?: boolean;
selectedVolumes?: string | null;
stopBeforeBackup?: boolean;
schedule?: string | null;
retention?: string | null;
options?: string | null;
tags?: string | null;
lastBackupAt?: string | null;
lastBackupStatus?: string | null;
}): Promise<BackupConfig | undefined> {
const updateData: Record<string, any> = { updatedAt: new Date().toISOString() };
if (data.destinationId !== undefined) updateData.destinationId = data.destinationId;
if (data.enabled !== undefined) updateData.enabled = data.enabled;
if (data.allVolumes !== undefined) updateData.allVolumes = data.allVolumes;
if (data.selectedVolumes !== undefined) updateData.selectedVolumes = data.selectedVolumes;
if (data.stopBeforeBackup !== undefined) updateData.stopBeforeBackup = data.stopBeforeBackup;
if (data.schedule !== undefined) updateData.schedule = data.schedule;
if (data.retention !== undefined) updateData.retention = data.retention;
if (data.options !== undefined) updateData.options = data.options;
if (data.tags !== undefined) updateData.tags = data.tags;
if (data.lastBackupAt !== undefined) updateData.lastBackupAt = data.lastBackupAt;
if (data.lastBackupStatus !== undefined) updateData.lastBackupStatus = data.lastBackupStatus;
await db.update(backupConfigs).set(updateData).where(eq(backupConfigs.id, id));
return getBackupConfig(id);
}
export async function deleteBackupConfig(id: number): Promise<void> {
await db.delete(backupConfigs).where(eq(backupConfigs.id, id));
}