2 Commits

Author SHA1 Message Date
6b832510f7 Clean up leftover access when a member leaves a team
Leaving a team used to just delete the membership row, but the
member's team-scoped API keys and cached session still worked
afterward. Now leaving also revokes those API keys and invalidates
the session cache so access actually stops right away.
2026-07-13 22:04:40 +06:00
ca837670f9 Let teams and admins rename templates
Renaming a published template unpublishes it, so stale cross-team
references break loudly instead of silently pointing elsewhere.
Admins rename platform templates; teams rename their own, both
blocked on system base images.
2026-07-13 21:42:32 +06:00
16 changed files with 610 additions and 23 deletions

View File

@ -21,6 +21,9 @@ DELETE FROM sessions WHERE id = $1 AND user_id = $2;
-- name: ListSessionsByUserID :many
SELECT * FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC;
-- name: ListActiveSessionsByUserID :many
SELECT * FROM sessions WHERE user_id = $1 AND expires_at > NOW() ORDER BY last_seen_at DESC;
-- name: DeleteSessionsByUserID :many
DELETE FROM sessions WHERE user_id = $1 RETURNING id;

View File

@ -7,8 +7,14 @@ RETURNING *;
SELECT * FROM templates WHERE id = $1;
-- name: GetTemplateByTeam :one
-- Platform templates (team_id = 00000000-...) are visible to all teams.
SELECT * FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000');
-- Platform templates (team_id = 00000000-...) are visible to all teams. When a
-- team owns a template whose name also matches a platform template, prefer the
-- team's own row so mutation guards act on the caller's template, not the
-- shared platform one.
SELECT * FROM templates
WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
ORDER BY (team_id = $2) DESC
LIMIT 1;
-- name: GetTemplateByName :one
-- Look up a template by team_id and name (exact team match, no global fallback).
@ -91,6 +97,16 @@ DELETE FROM templates WHERE team_id = $1;
-- name: UpdateTemplateSize :exec
UPDATE templates SET size_bytes = $2 WHERE id = $1;
-- name: RenameTemplate :one
-- Rename a template by ID. Clears the published flag so stale
-- "<team-slug>/<name>" references from other teams break loudly rather than
-- silently resolving to a different template. Callers apply ownership and
-- protection guards before calling. A name collision (team-scoped uniqueness or
-- the global-template-name trigger) surfaces as a unique_violation.
UPDATE templates SET name = @new_name, is_public = FALSE
WHERE id = @id
RETURNING *;
-- name: ListTemplatesByTeamOnly :many
-- List templates owned by a specific team (NOT including platform templates).
SELECT * FROM templates WHERE team_id = $1 ORDER BY created_at DESC;

View File

@ -105,6 +105,12 @@ export async function listAdminTemplates(): Promise<ApiResult<AdminTemplate[]>>
return apiFetch('GET', '/api/v1/admin/templates');
}
export async function renameAdminTemplate(name: string, newName: string): Promise<ApiResult<void>> {
return apiFetch('PATCH', `/api/v1/admin/templates/${encodeURIComponent(name)}`, {
new_name: newName
});
}
export async function deleteAdminTemplate(name: string): Promise<ApiResult<void>> {
return apiFetch('DELETE', `/api/v1/admin/templates/${name}`);
}

View File

@ -134,6 +134,16 @@ export async function deleteSnapshot(name: string): Promise<ApiResult<void>> {
return apiFetch('DELETE', `/api/v1/snapshots/${encodeURIComponent(name)}`);
}
/**
* Rename a template the team owns. Renaming unpublishes it, so any
* `<team_slug>/<name>` references other teams held stop resolving.
*/
export async function renameTemplate(name: string, newName: string): Promise<ApiResult<void>> {
return apiFetch('PATCH', `/api/v1/snapshots/${encodeURIComponent(name)}`, {
new_name: newName
});
}
/** Publish or unpublish a template the team owns. */
export async function setTemplateVisibility(
name: string,

View File

@ -10,6 +10,7 @@
createBuild,
listAdminTemplates,
deleteAdminTemplate,
renameAdminTemplate,
type Build,
type AdminTemplate
} from '$lib/api/builds';
@ -40,6 +41,12 @@
let deleting = $state(false);
let deleteError = $state<string | null>(null);
// Rename dialog.
let renameTarget = $state<AdminTemplate | null>(null);
let renameValue = $state('');
let renaming = $state(false);
let renameError = $state<string | null>(null);
// Create dialog state
let showCreate = $state(false);
let createForm = $state({
@ -79,7 +86,10 @@
const PLATFORM_TEAM_ID = 'team-0000000000000000000000000';
function canDeleteTemplate(tmpl: AdminTemplate): boolean {
// Platform, non-system templates are the only ones an admin can rename or
// delete here. Other teams' templates and the hardcoded system base
// templates are off-limits.
function canModifyTemplate(tmpl: AdminTemplate): boolean {
if (tmpl.protected) return false;
return tmpl.team_id === PLATFORM_TEAM_ID;
}
@ -177,6 +187,33 @@
deleting = false;
}
function openRename(tmpl: AdminTemplate) {
renameTarget = tmpl;
renameValue = tmpl.name;
renameError = null;
}
async function handleRenameTemplate() {
if (!renameTarget) return;
const oldName = renameTarget.name;
const newName = renameValue.trim();
if (!newName || newName === oldName) {
renameTarget = null;
return;
}
renaming = true;
renameError = null;
const result = await renameAdminTemplate(oldName, newName);
if (result.ok) {
templates = templates.map((t) => (t.name === oldName ? { ...t, name: newName } : t));
renameTarget = null;
toast.success('Template renamed');
} else {
renameError = result.error;
}
renaming = false;
}
function openBuild(buildId: string) {
goto(`/admin/templates/builds/${buildId}`);
}
@ -473,16 +510,28 @@
</span>
</td>
<td class="px-5 py-3.5 text-right">
<button
onclick={() => { deleteTarget = tmpl; deleteError = null; }}
disabled={!canDeleteTemplate(tmpl)}
title={tmpl.protected ? 'System base templates cannot be deleted' : !canDeleteTemplate(tmpl) ? 'Cannot delete templates owned by other teams' : undefined}
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta transition-all duration-150 {canDeleteTemplate(tmpl)
? 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]'
: 'text-[var(--color-text-muted)] cursor-not-allowed opacity-40'}"
>
Delete
</button>
<div class="flex items-center justify-end gap-1">
<button
onclick={() => openRename(tmpl)}
disabled={!canModifyTemplate(tmpl)}
title={tmpl.protected ? 'System base templates cannot be renamed' : !canModifyTemplate(tmpl) ? 'Only platform templates can be renamed here' : undefined}
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta transition-all duration-150 {canModifyTemplate(tmpl)
? 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]'
: 'text-[var(--color-text-muted)] cursor-not-allowed opacity-40'}"
>
Rename
</button>
<button
onclick={() => { deleteTarget = tmpl; deleteError = null; }}
disabled={!canModifyTemplate(tmpl)}
title={tmpl.protected ? 'System base templates cannot be deleted' : !canModifyTemplate(tmpl) ? 'Cannot delete templates owned by other teams' : undefined}
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta transition-all duration-150 {canModifyTemplate(tmpl)
? 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]'
: 'text-[var(--color-text-muted)] cursor-not-allowed opacity-40'}"
>
Delete
</button>
</div>
</td>
</tr>
{/each}
@ -789,6 +838,75 @@
</div>
{/if}
<!-- ── Rename Template ──────────────────────────────────────────────── -->
{#if renameTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60"
onclick={() => { if (!renaming) renameTarget = null; }}
onkeydown={(e) => { if (e.key === 'Escape' && !renaming) renameTarget = null; }}
></div>
<div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]"
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
>
<div class="p-6">
<h2 class="font-serif text-heading leading-tight text-[var(--color-text-bright)]">
Rename Template
</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Rename the platform template <code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[0.8rem] text-[var(--color-text-primary)]">{renameTarget.name}</code>.
</p>
<div class="mt-4">
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={renameValue}
autofocus
spellcheck="false"
autocomplete="off"
onkeydown={(e) => { if (e.key === 'Enter' && !renaming) handleRenameTemplate(); }}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-2 font-mono text-ui text-[var(--color-text-primary)] outline-none transition-colors duration-150 focus:border-[var(--color-border-mid)]"
/>
<p class="mt-1.5 text-meta text-[var(--color-text-tertiary)]">
Letters, digits, dot, dash, underscore. Max 64 characters.
</p>
</div>
{#if renameError}
<div class="mt-3 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{renameError}
</div>
{/if}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => (renameTarget = null)}
disabled={renaming}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleRenameTemplate}
disabled={renaming || !renameValue.trim() || renameValue.trim() === renameTarget.name}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if renaming}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Renaming…
{:else}
Rename
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- ── Delete Template Confirmation ────────────────────────────────── -->
{#if deleteTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center">

View File

@ -8,6 +8,7 @@
listSnapshots,
deleteSnapshot,
setTemplateVisibility,
renameTemplate,
createCapsule,
type Snapshot
} from '$lib/api/capsules';
@ -37,6 +38,12 @@
let deleting = $state(false);
let deleteError = $state<string | null>(null);
// Rename dialog.
let renameTarget = $state<Snapshot | null>(null);
let renameValue = $state('');
let renaming = $state(false);
let renameError = $state<string | null>(null);
// Row dropdown (split button chevron)
let openDropdownName = $state<string | null>(null);
let dropdownPos = $state<{ top: number; left: number }>({ top: 0, left: 0 });
@ -136,6 +143,33 @@
}
}
function openRename(s: Snapshot) {
openDropdownName = null;
renameTarget = s;
renameValue = s.name;
renameError = null;
}
async function handleRename() {
if (!renameTarget) return;
const newName = renameValue.trim();
if (!newName || newName === renameTarget.name) {
renameTarget = null;
return;
}
renaming = true;
renameError = null;
const result = await renameTemplate(renameTarget.name, newName);
if (result.ok) {
renameTarget = null;
// Refetch so the row reflects the new name and cleared publish flag.
await fetchSnapshots();
} else {
renameError = result.error;
}
renaming = false;
}
function openLaunch(snapshot: Snapshot) {
launchTarget = snapshot;
launchVcpus = snapshot.vcpus ?? 2;
@ -599,6 +633,20 @@
{/if}
</button>
<button
onclick={(e) => {
e.stopPropagation();
openRename(dropdownSnapshot);
}}
class="flex w-full items-center gap-2 px-3 py-2 text-meta text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Rename
</button>
<div class="my-1 h-px bg-[var(--color-border)]"></div>
<button
@ -689,6 +737,89 @@
</div>
{/if}
<!-- Rename Dialog -->
{#if renameTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60"
role="presentation"
onclick={() => { if (!renaming) renameTarget = null; }}
onkeydown={(e) => { if (e.key === 'Escape' && !renaming) renameTarget = null; }}
></div>
<div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]"
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
>
<div class="p-6">
<h2 class="font-serif text-heading leading-tight text-[var(--color-text-bright)]">Rename template</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Rename <code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[0.8rem] text-[var(--color-text-primary)]">{renameTarget.name}</code>.
</p>
{#if renameTarget.public}
<div class="mt-3 flex items-start gap-2 rounded-[var(--radius-input)] border border-[var(--color-amber)]/20 bg-[var(--color-amber)]/5 px-3 py-2.5">
<svg class="mt-0.5 shrink-0" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-amber)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<p class="text-meta leading-relaxed text-[var(--color-amber)]">
This template is published. Renaming unpublishes it — other teams' references stop working and you'll need to publish it again.
</p>
</div>
{/if}
<div class="mt-4">
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={renameValue}
autofocus
spellcheck="false"
autocomplete="off"
onkeydown={(e) => { if (e.key === 'Enter' && !renaming) handleRename(); }}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-2 font-mono text-ui text-[var(--color-text-primary)] outline-none transition-colors duration-150 focus:border-[var(--color-border-mid)]"
/>
<p class="mt-1.5 text-meta text-[var(--color-text-tertiary)]">
Letters, digits, dot, dash, underscore. Max 64 characters.
</p>
</div>
{#if renameError}
<div class="mt-3 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{renameError}
</div>
{/if}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => (renameTarget = null)}
disabled={renaming}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleRename}
disabled={renaming || !renameValue.trim() || renameValue.trim() === renameTarget.name}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if renaming}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Renaming...
{:else}
Rename
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- Launch Dialog -->
{#if launchTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center">

View File

@ -22,14 +22,15 @@ import (
)
type buildHandler struct {
svc *service.BuildService
db *db.Queries
pool *lifecycle.HostClientPool
audit *audit.AuditLogger
svc *service.BuildService
templateSvc *service.TemplateService
db *db.Queries
pool *lifecycle.HostClientPool
audit *audit.AuditLogger
}
func newBuildHandler(svc *service.BuildService, db *db.Queries, pool *lifecycle.HostClientPool, al *audit.AuditLogger) *buildHandler {
return &buildHandler{svc: svc, db: db, pool: pool, audit: al}
func newBuildHandler(svc *service.BuildService, templateSvc *service.TemplateService, db *db.Queries, pool *lifecycle.HostClientPool, al *audit.AuditLogger) *buildHandler {
return &buildHandler{svc: svc, templateSvc: templateSvc, db: db, pool: pool, audit: al}
}
type createBuildRequest struct {
@ -307,6 +308,43 @@ func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// RenameTemplate handles PATCH /v1/admin/templates/{name}. Renames a platform
// template. The hardcoded system base templates (minimal-ubuntu / -alpine /
// -arch / -fedora) cannot be renamed.
func (h *buildHandler) RenameTemplate(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if err := validate.SafeName(name); err != nil {
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid template name."))
return
}
ctx := r.Context()
var req renameTemplateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
tmpl, err := h.db.GetPlatformTemplateByName(ctx, name)
if err != nil {
writeErr(w, r, apperr.TemplateNotFound.Wrap(err))
return
}
if layout.IsSystemTemplate(tmpl.TeamID, tmpl.ID) {
writeErr(w, r, apperr.TemplateProtected.New())
return
}
if _, err := h.templateSvc.Rename(ctx, tmpl.ID, req.NewName); err != nil {
writeErr(w, r, err)
return
}
ac := auth.MustFromContext(ctx)
h.audit.LogTemplateRenameAdmin(ctx, ac, name, strings.TrimSpace(req.NewName))
w.WriteHeader(http.StatusNoContent)
}
// Cancel handles POST /v1/admin/builds/{id}/cancel.
func (h *buildHandler) Cancel(w http.ResponseWriter, r *http.Request) {
buildIDStr := chi.URLParam(r, "id")

View File

@ -252,6 +252,52 @@ func (h *snapshotHandler) SetVisibility(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent)
}
type renameTemplateRequest struct {
NewName string `json:"new_name"`
}
// Rename handles PATCH /v1/snapshots/{name}. Renames a template the team owns.
// Renaming clears the published flag (see TemplateService.Rename). Platform and
// system templates cannot be renamed here.
func (h *snapshotHandler) Rename(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if err := validate.SafeName(name); err != nil {
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid template name."))
return
}
ctx := r.Context()
ac := auth.MustFromContext(ctx)
var req renameTemplateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
tmpl, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: name, TeamID: ac.TeamID})
if err != nil {
writeErr(w, r, apperr.TemplateNotFound.Wrap(err))
return
}
// Platform templates can only be renamed by admins via /v1/admin/templates.
if tmpl.TeamID == id.PlatformTeamID {
writeErr(w, r, apperr.TemplateProtected.Msg("Platform templates cannot be renamed here."))
return
}
if layout.IsSystemTemplate(tmpl.TeamID, tmpl.ID) {
writeErr(w, r, apperr.TemplateProtected.New())
return
}
if _, err := h.svc.Rename(ctx, tmpl.ID, req.NewName); err != nil {
writeErr(w, r, err)
return
}
h.audit.LogTemplateRename(ctx, ac, name, strings.TrimSpace(req.NewName))
w.WriteHeader(http.StatusNoContent)
}
// Delete handles DELETE /v1/snapshots/{name}.
func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")

View File

@ -1745,6 +1745,53 @@ paths:
schema:
type: string
patch:
summary: Rename a template
operationId: renameTemplate
tags: [snapshots]
security:
- apiKeyAuth: []
- sessionAuth: []
description: |
Rename a template your team owns. Renaming unpublishes the template, so
any `<your-team-slug>/<name>` references other teams held stop resolving
and the owner must republish under the new name. Platform-owned and
system base templates cannot be renamed here.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [new_name]
properties:
new_name:
type: string
description: New template name (letters, digits, dot, dash, underscore; max 64).
responses:
"204":
description: Template renamed
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Cannot rename a platform-owned / system base template
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: Your team does not own a template with that name
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: A template with the new name already exists
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
delete:
summary: Delete a snapshot template
operationId: deleteSnapshot
@ -3247,6 +3294,40 @@ paths:
$ref: "#/components/schemas/AdminTemplate"
/v1/admin/templates/{name}:
patch:
summary: Rename a platform template (admin)
operationId: adminRenameTemplate
tags: [admin]
description: |
Rename a platform-owned template. The hardcoded system base templates
(minimal-ubuntu / -alpine / -arch / -fedora) cannot be renamed.
security:
- sessionAuth: []
parameters:
- name: name
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [new_name]
properties:
new_name:
type: string
description: New template name (letters, digits, dot, dash, underscore; max 64).
responses:
"204":
description: Template renamed
"403":
description: Cannot rename a system base template
"404":
description: No platform template with that name
"409":
description: A template with the new name already exists
delete:
summary: Delete a template (admin)
operationId: adminDeleteTemplate

View File

@ -123,7 +123,7 @@ func New(
statsH := newStatsHandler(statsSvc)
usageH := newUsageHandler(usageSvc)
metricsH := newSandboxMetricsHandler(queries, pool)
buildH := newBuildHandler(buildSvc, queries, pool, al)
buildH := newBuildHandler(buildSvc, templateSvc, queries, pool, al)
buildStreamH := newBuildStreamHandler(queries, buildBroker)
channelH := newChannelHandler(channelSvc, al)
ptyH := newPtyHandler(queries, pool)
@ -274,6 +274,7 @@ func New(
r.Post("/", snapshots.Create)
r.Get("/", snapshots.List)
r.Patch("/{name}/visibility", snapshots.SetVisibility)
r.Patch("/{name}", snapshots.Rename)
r.Delete("/{name}", snapshots.Delete)
})
@ -348,6 +349,7 @@ func New(
r.Put("/users/{id}/admin", usersH.SetUserAdmin)
r.Get("/audit-logs", auditH.AdminList)
r.Get("/templates", buildH.ListTemplates)
r.Patch("/templates/{name}", buildH.RenameTemplate)
r.Delete("/templates/{name}", buildH.DeleteTemplate)
r.Post("/builds", buildH.Create)
r.Get("/builds", buildH.List)

View File

@ -462,6 +462,16 @@ func (l *AuditLogger) LogTeamSlugChange(ctx context.Context, ac auth.AuthContext
l.Log(ctx, newEntry(ac, ac.TeamID, "team", "team", id.FormatTeamID(teamID), "slug_change", "info", map[string]any{"old_slug": oldSlug, "new_slug": newSlug}))
}
// LogTemplateRename records a team renaming one of its own templates.
func (l *AuditLogger) LogTemplateRename(ctx context.Context, ac auth.AuthContext, oldName, newName string) {
l.Log(ctx, newEntry(ac, ac.TeamID, "team", "template", oldName, "rename", "info", map[string]any{"old_name": oldName, "new_name": newName}))
}
// LogTemplateRenameAdmin records an admin renaming a platform template.
func (l *AuditLogger) LogTemplateRenameAdmin(ctx context.Context, ac auth.AuthContext, oldName, newName string) {
l.Log(ctx, newAdminEntry(ac, "template", oldName, "rename", "info", map[string]any{"old_name": oldName, "new_name": newName}))
}
func (l *AuditLogger) LogTemplateVisibility(ctx context.Context, ac auth.AuthContext, name string, public bool) {
action := "unpublish"
if public {

View File

@ -304,7 +304,7 @@ func (s *Service) RevokeAllForUser(ctx context.Context, userID pgtype.UUID) erro
// first. Backed by Postgres directly — the Redis cache is opportunistic and
// is not consulted here.
func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID) ([]db.Session, error) {
rows, err := s.db.ListSessionsByUserID(ctx, userID)
rows, err := s.db.ListActiveSessionsByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}

View File

@ -129,6 +129,40 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S
return i, err
}
const listActiveSessionsByUserID = `-- name: ListActiveSessionsByUserID :many
SELECT id, user_id, team_id, csrf_token, user_agent, ip_address, created_at, last_seen_at, expires_at FROM sessions WHERE user_id = $1 AND expires_at > NOW() ORDER BY last_seen_at DESC
`
func (q *Queries) ListActiveSessionsByUserID(ctx context.Context, userID pgtype.UUID) ([]Session, error) {
rows, err := q.db.Query(ctx, listActiveSessionsByUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Session
for rows.Next() {
var i Session
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.TeamID,
&i.CsrfToken,
&i.UserAgent,
&i.IpAddress,
&i.CreatedAt,
&i.LastSeenAt,
&i.ExpiresAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSessionsByUserID = `-- name: ListSessionsByUserID :many
SELECT id, user_id, team_id, csrf_token, user_agent, ip_address, created_at, last_seen_at, expires_at FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC
`

View File

@ -146,7 +146,10 @@ func (q *Queries) GetTemplateByName(ctx context.Context, arg GetTemplateByNamePa
}
const getTemplateByTeam = `-- name: GetTemplateByTeam :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata, is_public FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata, is_public FROM templates
WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
ORDER BY (team_id = $2) DESC
LIMIT 1
`
type GetTemplateByTeamParams struct {
@ -154,7 +157,10 @@ type GetTemplateByTeamParams struct {
TeamID pgtype.UUID `json:"team_id"`
}
// Platform templates (team_id = 00000000-...) are visible to all teams.
// Platform templates (team_id = 00000000-...) are visible to all teams. When a
// team owns a template whose name also matches a platform template, prefer the
// team's own row so mutation guards act on the caller's template, not the
// shared platform one.
func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamParams) (Template, error) {
row := q.db.QueryRow(ctx, getTemplateByTeam, arg.Name, arg.TeamID)
var i Template
@ -533,6 +539,42 @@ func (q *Queries) ListVisibleTemplates(ctx context.Context, arg ListVisibleTempl
return items, nil
}
const renameTemplate = `-- name: RenameTemplate :one
UPDATE templates SET name = $1, is_public = FALSE
WHERE id = $2
RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata, is_public
`
type RenameTemplateParams struct {
NewName string `json:"new_name"`
ID pgtype.UUID `json:"id"`
}
// Rename a template by ID. Clears the published flag so stale
// "<team-slug>/<name>" references from other teams break loudly rather than
// silently resolving to a different template. Callers apply ownership and
// protection guards before calling. A name collision (team-scoped uniqueness or
// the global-template-name trigger) surfaces as a unique_violation.
func (q *Queries) RenameTemplate(ctx context.Context, arg RenameTemplateParams) (Template, error) {
row := q.db.QueryRow(ctx, renameTemplate, arg.NewName, arg.ID)
var i Template
err := row.Scan(
&i.Name,
&i.Type,
&i.Vcpus,
&i.MemoryMb,
&i.SizeBytes,
&i.CreatedAt,
&i.TeamID,
&i.ID,
&i.DefaultUser,
&i.DefaultEnv,
&i.Metadata,
&i.IsPublic,
)
return i, err
}
const resolveTemplateForTeam = `-- name: ResolveTemplateForTeam :one
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata, is_public FROM templates
WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')

View File

@ -599,6 +599,26 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID pgtype
}); err != nil {
return fmt.Errorf("leave team: %w", err)
}
// Revoke the departing member's standing access to this team immediately,
// exactly as RemoveMember does. Deleting the membership row alone leaves two
// credentials live: the team-scoped API keys they created (resolved by hash
// with no membership re-check, never expiring), and any cached session still
// holding this team_id. Purge the keys, and drop the session cache so the
// next request rehydrates from Postgres (where the membership is now gone).
if err := s.DB.DeleteAPIKeysByTeamAndCreator(ctx, db.DeleteAPIKeysByTeamAndCreatorParams{
TeamID: teamID,
CreatedBy: callerUserID,
}); err != nil {
slog.Warn("failed to delete API keys for departing member",
"team_id", teamID, "user_id", callerUserID, "error", err)
}
if s.Sessions != nil {
if err := s.Sessions.InvalidateCacheForUser(ctx, callerUserID); err != nil {
slog.Warn("failed to invalidate session cache for departing member",
"user_id", callerUserID, "error", err)
}
}
return nil
}

View File

@ -2,12 +2,16 @@ package service
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/validate"
)
// TemplateService provides template/snapshot operations shared between the
@ -79,3 +83,29 @@ func (s *TemplateService) SetVisibility(ctx context.Context, teamID pgtype.UUID,
}
return tmpl, nil
}
// Rename changes a template's name by ID. It validates the new name, then clears
// the published flag (see RenameTemplate) so stale "<team-slug>/<name>"
// references break loudly instead of silently resolving elsewhere. Callers must
// apply ownership and protection guards before calling. A name collision — with
// another of the team's templates or with a reserved platform name — is returned
// as a Conflict.
func (s *TemplateService) Rename(ctx context.Context, id pgtype.UUID, newName string) (db.Template, error) {
newName = strings.TrimSpace(newName)
if err := validate.SafeName(newName); err != nil {
return db.Template{}, apperr.InvalidRequest.WrapMsg(err, "Invalid template name.")
}
tmpl, err := s.DB.RenameTemplate(ctx, db.RenameTemplateParams{ID: id, NewName: newName})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return db.Template{}, apperr.Conflict.Msgf("A template named %q already exists.", newName)
}
if err == pgx.ErrNoRows {
return db.Template{}, apperr.TemplateNotFound.New()
}
return db.Template{}, apperr.Internal.Wrap(err)
}
return tmpl, nil
}