Commit Graph
563 Commits
Author SHA1 Message Date
mrhid6 9df4a29210 fix: Separate stacked securityDefinitions into distinct comment groups
swag v2.0.0-rc5's parseSecAttributesV3 resolves a security scheme's map key
via getSecurityDefinitionKey(lines), which scans from the start of whatever
comment-line slice it was handed and returns the first @securitydefinitions
match — ignoring the current parse position entirely. Three
@securityDefinitions.apikey blocks stacked in one Go comment group (the
three were separated only by bare '//' lines, which do not split an
ast.CommentGroup) therefore all resolved to the first block's name
(cookieAuth), with the last block's in/name/description winning: the
generated document had exactly one securityScheme, keyed cookieAuth, body
esoAuth.

Separating the three blocks with real blank source lines splits them into
three distinct ast.CommentGroups, so swag's file-level comment scan (which
requires no other tokens between them, same rule Go uses for doc comments)
hands each block its own line slice and each resolves its own key.
Regenerated openapi.json now carries all three schemes with correct
bodies, referenced with no dangling security requirements.
2026-08-13 07:54:56 +00:00
mrhid6 4c88d6e768 feat: Publish an OpenAPI 3.1 document and a Scalar reference
Generated from swaggo v2 annotations, committed rather than built into the
image: the runtime stage is scratch and adding codegen puts the toolchain
in the build. CI regenerates and diffs, so an annotation edited without
regenerating fails the build — without that the annotations would drift
while still looking authoritative.

Scalar is vendored rather than loaded from a CDN, because air-gapped
self-hosted installs are supported and a reference page that fails closed
offline is a support ticket.
2026-08-12 15:23:02 +00:00
mrhid6 a85a354e57 feat: Annotate workflow, step, run and workload routes
Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. This is the last of the handler files under
server/internal/api/.
2026-08-12 15:22:54 +00:00
mrhid6 9b18d09d9b feat: Annotate monitor, secrets and vulnerability routes
Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. VulnSummaryResponse uses pointer fields so the
db-freshness block stays entirely absent when no vulndb_meta document
exists yet, matching the handler's original conditional gin.H exactly.
2026-08-12 15:22:51 +00:00
mrhid6 a398da0eac feat: Annotate SSO, channel, console, instance and licence routes
Same treatment as the previous commit: named types replace gin.H literals,
and every handler gets a swaggo doc block.
2026-08-12 15:22:48 +00:00
mrhid6 edb8406e05 feat: Annotate server, key and token routes for OpenAPI
Converts their gin.H responses to the named types added in the previous
commit and adds swaggo doc blocks for every handler in handlers.go and
tokens.go.
2026-08-12 15:22:45 +00:00
mrhid6 bfd185adbb feat: Add OpenAPI response types and top-level swag annotations
Named response types for handlers that were returning anonymous gin.H
literals, so a generated annotation and what the handler actually returns
cannot disagree. main.go carries the top-level swaggo info block (title,
description, security schemes for cookie, bearer token and ESO auth).
2026-08-12 15:22:42 +00:00
mrhid6 182752d9ab feat: Manage API tokens from settings
A card in the Access group beside Members and single sign-on rather than a
new nav entry — /settings/instance was folded back in for exactly this
reason. The plaintext is shown once in a well block and never again.

Tokens outside a newly tightened lifetime policy are flagged rather than
broken, because the policy governs issuance, not existing credentials.
2026-08-12 14:58:57 +00:00
mrhid6 3b4c87a292 feat: Rate limit API token requests
600 per minute per token, in the Redis that sessions already require.
Cookie sessions are untouched. A Redis failure falls through rather than
refusing traffic — it is already a larger problem and should not become a
second outage.
2026-08-12 14:51:22 +00:00
mrhid6 2685e9ad06 fix: Distinguish caller mistakes from backend failures in CreateAPIToken
createToken's catch-all mapped every unmatched error to 400, so a
database outage reported itself as a malformed client request. Wrap the
genuine validation failures with ErrTokenInvalid and let the handler
answer 500 with a fixed message for everything else.
2026-08-12 14:48:17 +00:00
mrhid6 4de67e4bea docs: Separate caller mistakes from backend failures in the plan
createToken's catch-all answered 400 for every unmatched error, so a
database failure reported itself as the caller's malformed request. Found
in review of Task 7.
2026-08-12 14:47:36 +00:00
mrhid6 524ccc6412 feat: Add the API token endpoints
Create, list and revoke, with no update: editing what a credential already
deployed in CI can do, with no record of what it could do before, is worse
than requiring a rotation. Revoking a token that is not yours answers
not-found, since a 403 confirms it exists.

The audit actor stays the human and names the credential alongside, so a
person clicking and their CI job are told apart.
2026-08-12 14:44:50 +00:00
mrhid6 be4f488db3 feat: Enforce API token scopes from the route map
Keyed on the registered gin route pattern rather than a per-route
decorator, because a route registered without a decorator would be
unguarded. An unmapped route reached by a token is a 403, and a boot-time
check refuses to start when any /api route is missing, so the failure
lands at deploy rather than as a customer's surprise 403.
2026-08-12 14:40:00 +00:00
mrhid6 2f60b81962 fix: Stop the session middleware writing two responses on an expired cookie
sessionFromCookie already writes "session expired" when a cookie was
presented and rejected with no bearer to fall through to. Middleware
called sessionFromToken anyway, which wrote a second "not authenticated"
body onto the same response for every ordinary browser-session timeout -
gin logged "superfluous response.WriteHeader call" on ordinary use, not a
rare edge case.

Guard on c.IsAborted() after sessionFromCookie: true only in that one
rejected-cookie-no-bearer branch, so it short-circuits there while the
other three credential paths (no credential, bearer only, stale cookie
plus valid bearer) are unaffected.
2026-08-12 14:35:20 +00:00
mrhid6 92692de94d docs: Correct the middleware fallback in the plan
The plan's Middleware called sessionFromToken even when sessionFromCookie
had already answered a rejected cookie, putting two JSON bodies on the
wire for the ordinary expired-session case. Found in review of Task 5.
2026-08-12 14:34:54 +00:00
mrhid6 a5f9fca59e feat: Authenticate the API with a bearer token as well as a cookie
One middleware, two ways to arrive at the same *Session, so every handler,
role guard, licence gate and audit call is untouched. The host guard
applies to both: a token carries an instance, and the tenant boundary must
not have a token-shaped hole in it.

The effective role is min(user, token) recomputed per request, so demoting
somebody demotes their tokens with them. A stale cookie beside a valid
bearer falls through rather than refusing a credential that would work.
2026-08-12 14:32:40 +00:00
mrhid6 72e5228351 feat: Add the API token service
Mint, resolve, list and revoke, with the effective role capped at the
owner's and recomputed per request rather than frozen at creation.

Deleting a user deletes their tokens in the same call, so offboarding is
one action. Revoking somebody else's token answers not-found rather than
forbidden, since a 403 confirms the credential exists.

Also re-exports shared.APITokenMaxDays into server/internal/models,
following the existing ValidRole wrapper pattern, since the token
service needs it and it was never re-exported.
2026-08-12 14:28:10 +00:00
mrhid6 33b5ec0788 feat: Add a per-instance API token lifetime cap
A pointer with absent meaning no cap, so an upgrade allows never-expire
tokens exactly as before and an instance opts into the policy. It governs
issuance only: changing it never invalidates a token that already exists.
2026-08-12 14:22:12 +00:00
mrhid6 1b718e7c59 feat: Define the API token scope vocabulary
Eight resources with read and write, write implying read. Coarse on
purpose: a scope per endpoint is a table nobody maintains, and a route
added without an entry either breaks or is unguarded.
2026-08-12 14:19:47 +00:00
mrhid6 6ad65a1242 feat: Add the api_tokens collection and its indexes
The unique index on token_hash is what makes authentication an indexed
lookup rather than a scan, so this builder is fatal on failure like
EnsureAuthIndexes rather than warning like the secrets one.

Registered in ScopedCollections so instance purge reaches it.
2026-08-12 14:16:12 +00:00
mrhid6 a41f2b26cc docs: Plan the API token and OpenAPI implementation
Twelve tasks from model through middleware, scope enforcement, endpoints,
web UI, generated OpenAPI and documentation. Verification is build plus
curl and UI checks rather than test cycles, matching a repository with no
Go test harness beyond shared/mail.
2026-08-12 14:09:03 +00:00
mrhid6 71f9a9dca5 docs: Specify scoped API tokens and an OpenAPI reference
Adds the approved design for personal access tokens on the control plane
REST API, and for the generated OpenAPI 3.1 document served as a Scalar
reference page.

Tokens fall back into the existing session middleware rather than getting
their own route group, so every handler, role guard and audit call works
unchanged. Scope enforcement derives from the registered route pattern and
fails closed, with a boot-time check for unmapped routes.

A Terraform provider is deliberately left to a follow-on spec.
2026-08-12 13:59:03 +00:00
mrhid6 449684ceaa fix: Derive the rename unwind deadline at its use site 2026-08-12 11:15:29 +00:00
mrhid6 cdc50b7aaf fix: Harden instance rename against interleaving and lost unwinds 2026-08-12 11:08:36 +00:00
mrhid6 f534b74066 docs: Document instance rename in HQ 2026-08-12 10:52:43 +00:00
mrhid6 3c15ee15ef feat: Let staff rename an instance 2026-08-12 10:29:45 +00:00
mrhid6 45a4f968d6 feat: Let a customer rename a cloud instance from HQ 2026-08-12 10:23:17 +00:00
mrhid6 df09e42cf2 feat: Add rename calls and slug preview to the HQ client 2026-08-12 10:17:39 +00:00
mrhid6 ee427ed6e1 feat: Add staff instance rename endpoint 2026-08-12 10:12:04 +00:00
mrhid6 5856deede3 feat: Add customer instance rename endpoint 2026-08-12 10:09:19 +00:00
mrhid6 e02b263054 feat: Add rename cooldown field and cloudprov rename 2026-08-12 10:06:31 +00:00
mrhid6 1d6c89c368 feat: Add instance rename to shared provisioning 2026-08-12 10:01:56 +00:00
mrhid6 0edfddb710 docs: Drop test steps from the rename plan 2026-08-12 09:58:18 +00:00
mrhid6 71a4f53bed docs: Implementation plan for instance rename in HQ 2026-08-12 09:53:40 +00:00
mrhid6 de7350fce9 docs: Reuse loginURLFor for the rename response host 2026-08-12 09:46:38 +00:00
mrhid6 21786fa1a8 docs: Design for instance rename in Vantage HQ 2026-08-12 09:46:08 +00:00
mrhid6 84f9587b9a fix: Fixed gitignore 2026-08-11 15:03:03 +00:00
mrhid6 a20e165ac2 feat: Revamp instance page 2026-08-11 10:32:59 +01:00
mrhid6 ed260cd86c feat: Updated members panel on instance page 2026-08-11 10:10:44 +01:00
mrhid6 99cd2a8ec3 feat: Updated admin instance page 2026-08-11 09:59:27 +01:00
mrhid6 a228ab24a0 feat: Changes to self hosted purchase 2026-08-11 09:30:19 +01:00
mrhid6 3acbf01d46 docs: Self review of doc pages 2026-08-10 16:32:23 +01:00
mrhid6 675689a458 feat(audit): server-side paging, search and category filter; one event format
The page rendered a map of eleven event types to labels and seven to colours.
The server emits forty-seven. Everything unmapped fell through to its raw
string, so "Key Assigned" in green sat above "workflow.schedule_updated" in
grey — the same kind of fact in two formats, which made the column look like it
carried a meaning it did not.

Presentation is now derived rather than enumerated. Event types are named
<category>.<action> by every call site, so the category becomes a chip, the
action is humanised, and the tone comes from the verb. A type added to the
server tomorrow gets a sensible label and colour with no second list to update;
the override table holds only the dozen the rule reads badly for. Every row is
one treatment, and colour never carries meaning alone — the sentence beside it
says the same thing in words.

Paging and filtering are server-side, unlike the fleet lists that answer with
everything and slice in the browser. audit_retention_days is a licensed
entitlement measured in months, and this log is read to answer questions about
the past, so a browser filtering the most recent page would report "no results"
for events that exist. GET /api/audit now takes q, category, limit and skip and
answers {events, total} — a short page is not evidence of the end of the log,
which is why the total is counted rather than inferred.

audit_logs had no indexes at all: every read was a collection scan with an
in-memory sort over an append-only collection. Adds (instance_id, created_at)
and warns rather than failing, matching EnsureSecretIndexes.

Two bugs found by running the deriver over all forty-seven real types rather
than eyeballing it: the tone rules matched only past-tense verbs, leaving
auth_provider.delete drawn as neutral beside key.deleted in red; and
"unaccepted" matched "accepted", so withdrawing an acceptance read as the same
caution as granting one.
2026-08-10 15:25:48 +01:00
mrhid6 42f3f3e640 feat(web): typed confirmation for deleting an SSH key
The last of the inline two-step deletes, and the one with the most reach: a
key delete revokes it from every server at once, and for a generated key the
stored private half goes with it. That copy is the only one Vantage holds, so
unlike an uploaded key this cannot be undone by pasting the public half back.

Body names how many servers lose access, and says the private key is destroyed
only when there is one to destroy.

The delete error moves out of the toast and into the dialog, which stays open
on failure, matching the server and monitor deletes.
2026-08-10 15:07:52 +01:00
mrhid6 21238fe707 feat(web): typed confirmations on destructive actions; toasts for API outcomes
Destructive actions were confirmed by a second danger button rendered where
the first one had been, so a double click on Remove deleted the thing without
the operator ever reading which thing it was. Servers, monitors, secret keys
and sign-in providers now go through ConfirmDialog with requireTyped, matching
the secret-group delete that already worked this way. Notification channels and
vulnerability alert rules get an untyped dialog: both are a name and a URL and
are rebuilt in a minute, but neither had any confirmation at all, and both
silently stop alerts that nobody misses until an incident goes unannounced.

Mutations otherwise succeeded in silence, or reported into whatever inline
banner the page happened to own. Two failure modes came of that: a modal that
closed on error left the message nowhere to land, and a save that was rejected
left the old values on screen looking exactly like a save that worked
(/settings had no error path at all). Every mutation now reports through the
existing toast context.

Errors stay inline where the surface that raised them is still on screen and
the message is a correction to make in it: form validation, the cron field,
the tag rows, a rejected licence blob, and the workflow designer's autosave,
which is a standing condition rather than an event. Everything else toasts.

Ad-hoc feedback removed in favour of it: the "Sent!" button labels on the
server maintenance tab, the settings "Saved!" flag, the channel test line, and
the steps page's notice/error pair.
2026-08-10 14:26:02 +01:00
mrhid6 ef86ef04a1 fix: Fixes to command stream 2026-08-10 14:07:17 +01:00
mrhid6 727bb09eff feat: More updates to hq 2026-08-10 13:56:59 +01:00
mrhid6 1fe4ba5999 feat: HQ redesign 2026-08-10 10:44:30 +01:00
mrhid6 e434beec7a fix(web): toasts survive an open dialog; empty-state actions can be gated
Both from the final review, and the first one overturns a call I got wrong.

The aria-hidden sweep that makes aria-modal true also swallowed the toasts.
ToastProvider renders inside the app root, and every modal-raised confirmation
is toasted *before* its dialog closes — "Saved …", "Deleted …", "Removed …" —
so each one was inserted into a hidden subtree and never announced. Un-hiding
a live region afterwards does not replay what it missed. The toast layer is
portalled to the body carrying the dialog-layer attribute, which exempts it
from the sweep, and sits above the dialog: a toast explaining why a dialog's
action failed is no use behind it.

EmptyState's action was narrower than the call site it replaced. The old
first-workflow button carried loading={isPending}; the new one carried
nothing, so a double click created two workflows. The action is a union now —
a link takes no pending state, a handler takes loading and disabled.
2026-08-10 10:01:47 +01:00
mrhid6 fe1dfe472a refactor(web): list pages share the async primitives; dialogs hide the page behind
Keys, secrets, audit, steps and workflows each carried the same
loading/error/empty ternary with its own copy of the spinner and its own
wording for a failed fetch — five of them had drifted to five different
sentences for "the request did not come back". They go through AsyncBoundary
now, which means a skeleton in place of a spinner, a retry button on failure,
and backend messages passed through friendlyMessage rather than printed raw.

Steps gets the filtered-empty state the fleet just got: "no steps match that
filter" is not "no steps yet", and only one of them should offer to create the
first one.

Two more from review:

Modal's effect ran before `mounted` flipped, so a dialog rendered already open
found null refs and took no focus at all. It depends on `mounted` now.

aria-modal was a claim with no mechanism behind it — portalled to the body,
the app tree is a sibling of the dialog and a screen reader's virtual cursor
still browsed the page underneath. The body's other children are marked
aria-hidden while any dialog is open, refcounted alongside the scroll lock.
This does mean a toast raised while a dialog is open is not announced, which
is the correct trade for a modal: ConfirmDialog shows its own errors inline.
2026-08-10 09:54:32 +01:00