From 2ea8a9f22a193eace6ad19fd76c9c359432ec1d8 Mon Sep 17 00:00:00 2001 From: Kurt Jacobson Date: Wed, 15 Jul 2026 20:19:26 -0400 Subject: [PATCH 01/12] Accept optional valid_until when creating a license via admin API Allows manually issued licenses to carry a fixed expiry date (e.g. invoice-based one-year licenses). Empty keeps the existing behavior: trial plans expire after trial_days, others are perpetual. --- internal/handler/admin.go | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 968bbad..bf11069 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -846,6 +846,10 @@ func (h *AdminHandler) CreateLicense(c *gin.Context) { Notes string `json:"notes"` ExternalCustomerID string `json:"external_customer_id"` ExternalWorkspaceID string `json:"external_workspace_id"` + // ValidUntil sets an explicit expiry (RFC 3339). Empty means the + // plan decides: trial plans get now+trial_days, everything else + // is perpetual. When set it wins over the trial default. + ValidUntil string `json:"valid_until"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "product_id, plan_id, and email are required") @@ -868,6 +872,20 @@ func (h *AdminHandler) CreateLicense(c *gin.Context) { return } + var validUntil *time.Time + if req.ValidUntil != "" { + ts, err := time.Parse(time.RFC3339, req.ValidUntil) + if err != nil { + response.BadRequest(c, "valid_until must be an RFC 3339 timestamp") + return + } + if !ts.After(time.Now()) { + response.BadRequest(c, "valid_until must be in the future") + return + } + validUntil = &ts + } + // Look up plan first to determine license type and set appropriate fields plan, err := h.Store.FindPlanByID(c, req.PlanID) if err != nil { @@ -906,8 +924,11 @@ func (h *AdminHandler) CreateLicense(c *gin.Context) { ExternalWorkspaceID: req.ExternalWorkspaceID, } - // Set valid_until for trial licenses - if plan.LicenseType == "trial" && plan.TrialDays > 0 { + // Set valid_until: an explicit request value wins, otherwise trial + // plans default to now+trial_days and other plans stay perpetual. + if validUntil != nil { + l.ValidUntil = validUntil + } else if plan.LicenseType == "trial" && plan.TrialDays > 0 { until := time.Now().Add(time.Duration(plan.TrialDays) * 24 * time.Hour) l.ValidUntil = &until } From 0b444e3317ef7aee154de465d07f867a3b856631 Mon Sep 17 00:00:00 2001 From: Kurt Jacobson Date: Wed, 15 Jul 2026 20:21:03 -0400 Subject: [PATCH 02/12] Add admin endpoint to set or clear a license's valid_until MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /admin/licenses/:id/valid-until with an RFC 3339 timestamp sets the expiry; an empty value clears it (perpetual). Status is deliberately untouched — reinstating an expired license remains an explicit separate action. --- cmd/server/main.go | 1 + internal/handler/admin.go | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/cmd/server/main.go b/cmd/server/main.go index ab88ad9..724dbce 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -845,6 +845,7 @@ func main() { licWrite.POST("/licenses/:id/revoke", adminH.RevokeLicense) licWrite.POST("/licenses/:id/suspend", adminH.SuspendLicense) licWrite.POST("/licenses/:id/reinstate", adminH.ReinstateLicense) + licWrite.POST("/licenses/:id/valid-until", adminH.SetLicenseValidUntil) licWrite.POST("/licenses/:id/change-plan", adminH.ChangeLicensePlan) licWrite.GET("/licenses/:id/usage", adminH.ListLicenseUsage) licWrite.POST("/licenses/:id/usage/reset", adminH.ResetLicenseUsage) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index bf11069..c52b245 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -1088,6 +1088,54 @@ func (h *AdminHandler) ReinstateLicense(c *gin.Context) { response.OK(c, gin.H{"status": "active"}) } +// SetLicenseValidUntil sets or clears a license's expiry date. An +// empty valid_until makes the license perpetual. Extending an +// already-expired license does not change its status — use +// /reinstate for that (the two concerns stay separate so an +// accidental date edit can't silently re-arm a revoked customer). +func (h *AdminHandler) SetLicenseValidUntil(c *gin.Context) { + id := c.Param("id") + if !h.checkLicenseScope(c, id) { + return + } + var req struct { + ValidUntil string `json:"valid_until"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body") + return + } + + lic, err := h.Store.FindLicenseByID(c, id) + if err != nil { + response.NotFound(c, "license not found") + return + } + + var validUntil *time.Time + if req.ValidUntil != "" { + ts, err := time.Parse(time.RFC3339, req.ValidUntil) + if err != nil { + response.BadRequest(c, "valid_until must be an RFC 3339 timestamp or empty") + return + } + validUntil = &ts + } + + lic.ValidUntil = validUntil + if err := h.Store.UpdateLicense(c, lic, "valid_until"); err != nil { + response.Internal(c) + return + } + + h.Store.Audit(c, &model.AuditLog{ + Entity: "license", EntityID: id, Action: "valid_until_changed", + ActorType: "admin", ActorID: adminID(c), + Changes: map[string]any{"valid_until": req.ValidUntil}, + }) + response.OK(c, lic) +} + func (h *AdminHandler) DeleteActivation(c *gin.Context) { id := c.Param("id") pid, err := h.Store.GetActivationProductID(c, id) From f1675a2078be4a33be65d2022ea126608ec8bc62 Mon Sep 17 00:00:00 2001 From: Kurt Jacobson Date: Wed, 15 Jul 2026 20:47:40 -0400 Subject: [PATCH 03/12] Add API client method and i18n strings for license expiry editing --- web/src/i18n/locales/en.ts | 4 ++++ web/src/i18n/locales/zh.ts | 4 ++++ web/src/lib/api.ts | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 24b984f..03c4a63 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -125,6 +125,10 @@ const en = { "licenses.issue": "Issue License", "licenses.licenseKey": "License Key", "licenses.validUntil": "Valid Until", + "licenses.validUntilOptional": "Valid until (optional)", + "licenses.validUntilHint": "Leave empty for a perpetual license (trials still use the plan's trial days).", + "licenses.validUntilEdit": "Edit expiry", + "licenses.validUntilClear": "Leave empty to make the license perpetual.", "licenses.empty": "No licenses found", "licenses.noProducts": "No products yet", "licenses.noProductsDesc": "Create a product and plan first before issuing licenses.", diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 6bdbb1a..2380248 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -123,6 +123,10 @@ const zh = { "licenses.issue": "签发许可证", "licenses.licenseKey": "许可证密钥", "licenses.validUntil": "有效期至", + "licenses.validUntilOptional": "有效期至(可选)", + "licenses.validUntilHint": "留空表示永久许可证(试用版仍按套餐的试用天数计算)。", + "licenses.validUntilEdit": "编辑到期时间", + "licenses.validUntilClear": "留空表示永久许可证。", "licenses.empty": "未找到许可证", "licenses.noProducts": "暂无产品", "licenses.noProductsDesc": "请先创建产品和方案,然后再签发许可证。", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 50c6229..85b4866 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -202,7 +202,11 @@ export const admin = { notes?: string external_customer_id?: string external_workspace_id?: string + valid_until?: string }) => post("/admin/licenses", data), + // Empty valid_until clears the expiry (perpetual license). + setLicenseValidUntil: (id: string, validUntil: string) => + post(`/admin/licenses/${id}/valid-until`, { valid_until: validUntil }), revokeLicense: (id: string) => post(`/admin/licenses/${id}/revoke`), suspendLicense: (id: string) => post(`/admin/licenses/${id}/suspend`), reinstateLicense: (id: string) => post(`/admin/licenses/${id}/reinstate`), From 66b5925b7702abc67ef174d01537fc85c29493e3 Mon Sep 17 00:00:00 2001 From: Kurt Jacobson Date: Wed, 15 Jul 2026 20:47:40 -0400 Subject: [PATCH 04/12] Admin UI: set expiry when issuing a license, edit it in detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue dialog gains an optional date field (empty = perpetual; trial plans keep their trial_days default). The detail view shows a pencil next to Valid Until for non-Stripe licenses — Stripe-billed expiry is renewal-managed, so editing it there stays hidden. Dates are sent as end-of-day UTC. --- web/src/pages/admin/licenses.tsx | 75 +++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/web/src/pages/admin/licenses.tsx b/web/src/pages/admin/licenses.tsx index 42b30a6..ef423e5 100644 --- a/web/src/pages/admin/licenses.tsx +++ b/web/src/pages/admin/licenses.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Ban, Check, Copy, Eye, Package, Pause, Play, Plus, RefreshCw, Search, Trash2 } from "lucide-react" +import { Ban, Check, Copy, Eye, Package, Pause, Pencil, Play, Plus, RefreshCw, Search, Trash2 } from "lucide-react" import { useState } from "react" import { Link } from "react-router-dom" import { showToast } from "@/components/toast" @@ -260,6 +260,7 @@ function CreateLicenseDialog({ notes?: string external_customer_id?: string external_workspace_id?: string + valid_until?: string }) => void loading: boolean }) { @@ -270,6 +271,7 @@ function CreateLicenseDialog({ const [planId, setPlanId] = useState("") const [externalCustomerID, setExternalCustomerID] = useState("") const [externalWorkspaceID, setExternalWorkspaceID] = useState("") + const [validUntil, setValidUntil] = useState("") const { data: plansData } = useQuery({ queryKey: ["admin", "plans", productId], @@ -310,6 +312,9 @@ function CreateLicenseDialog({ notes, external_customer_id: externalCustomerID.trim() || undefined, external_workspace_id: externalWorkspaceID.trim() || undefined, + // Date-only input → expire at end of that day, UTC. Good + // enough for invoice-style licensing without a time picker. + valid_until: validUntil ? `${validUntil}T23:59:59Z` : undefined, }) }} className="space-y-4" @@ -360,6 +365,16 @@ function CreateLicenseDialog({ setNotes(e.target.value)} /> +
+ + setValidUntil(e.target.value)} + /> +

{t("licenses.validUntilHint")}

+
{/* External identifiers — opaque strings the merchant uses to map their own user/workspace model to this license. Both optional; leave blank if not integrating with an @@ -404,6 +419,8 @@ function LicenseDetail({ id, onClose }: { id: string; onClose: () => void }) { const { data: lic, isLoading } = useQuery({ queryKey: ["admin", "license", id], queryFn: () => admin.getLicense(id) }) const [copied, setCopied] = useState(false) const [changingPlan, setChangingPlan] = useState(false) + // null = not editing; "" = editing with empty value (perpetual) + const [editingValidUntil, setEditingValidUntil] = useState(null) const revokeMut = useMutation({ mutationFn: () => admin.revokeLicense(id), @@ -429,6 +446,13 @@ function LicenseDetail({ id, onClose }: { id: string; onClose: () => void }) { qc.invalidateQueries({ queryKey: ["admin"] }) }, }) + const validUntilMut = useMutation({ + mutationFn: (validUntil: string) => admin.setLicenseValidUntil(id, validUntil), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin"] }) + setEditingValidUntil(null) + }, + }) // Activation deletion is destructive — wrap in a confirmation // state so a stray ghost-click on the trash icon (icons sit // close together in the row) doesn't immediately revoke a device. @@ -497,7 +521,54 @@ function LicenseDetail({ id, onClose }: { id: string; onClose: () => void }) {

{t("licenses.validUntil")}

-

{formatDate(lic.valid_until)}

+ {editingValidUntil === null ? ( +
+

{formatDate(lic.valid_until)}

+ {/* Stripe owns the expiry for subscription licenses — + editing it here would be overwritten on renewal. */} + {lic.payment_provider !== "stripe" && ( + + )} +
+ ) : ( +
+
+ setEditingValidUntil(e.target.value)} + /> + + +
+

{t("licenses.validUntilClear")}

+
+ )}
{lic.payment_provider && (
From 8c8e66e111e7bf8115ae1159eeca50bd257bb5d5 Mon Sep 17 00:00:00 2001 From: Kurt Jacobson Date: Wed, 15 Jul 2026 21:19:55 -0400 Subject: [PATCH 05/12] Interpret expiry dates as end-of-day in the admin's local timezone Previously the picked date became 23:59:59 UTC, which renders as a confusing mid-evening time for admins west of Greenwich. The date input's prefill is converted back the same way so editing doesn't shift the day. --- web/src/pages/admin/licenses.tsx | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/web/src/pages/admin/licenses.tsx b/web/src/pages/admin/licenses.tsx index ef423e5..83d495d 100644 --- a/web/src/pages/admin/licenses.tsx +++ b/web/src/pages/admin/licenses.tsx @@ -36,6 +36,24 @@ import { useI18n } from "@/i18n" import { admin } from "@/lib/api" import { formatDate, statusColor } from "@/lib/utils" +// A date picked in the expiry field means "valid through that whole +// day" in the admin's local timezone, so the license dies at local +// 23:59:59 rather than end-of-day UTC (which renders as an odd +// mid-evening time for anyone west of Greenwich). +function endOfDayISO(date: string): string { + const [y, m, d] = date.split("-").map(Number) + return new Date(y, m - 1, d, 23, 59, 59).toISOString() +} + +// Inverse of endOfDayISO for prefilling the date input: the stored +// instant rendered as a local YYYY-MM-DD (slicing the UTC string +// would land on the next day for anyone west of Greenwich). +function localDateValue(iso: string): string { + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, "0") + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + export default function LicensesPage() { const { t } = useI18n() const qc = useQueryClient() @@ -312,9 +330,7 @@ function CreateLicenseDialog({ notes, external_customer_id: externalCustomerID.trim() || undefined, external_workspace_id: externalWorkspaceID.trim() || undefined, - // Date-only input → expire at end of that day, UTC. Good - // enough for invoice-style licensing without a time picker. - valid_until: validUntil ? `${validUntil}T23:59:59Z` : undefined, + valid_until: validUntil ? endOfDayISO(validUntil) : undefined, }) }} className="space-y-4" @@ -532,7 +548,7 @@ function LicenseDetail({ id, onClose }: { id: string; onClose: () => void }) { size="icon" className="h-6 w-6" title={t("licenses.validUntilEdit")} - onClick={() => setEditingValidUntil(lic.valid_until ? lic.valid_until.slice(0, 10) : "")} + onClick={() => setEditingValidUntil(lic.valid_until ? localDateValue(lic.valid_until) : "")} > @@ -552,7 +568,7 @@ function LicenseDetail({ id, onClose }: { id: string; onClose: () => void }) { className="h-7" disabled={validUntilMut.isPending} onClick={() => - validUntilMut.mutate(editingValidUntil ? `${editingValidUntil}T23:59:59Z` : "") + validUntilMut.mutate(editingValidUntil ? endOfDayISO(editingValidUntil) : "") } > {t("common.save")} From f4bac0de18200fa43a44df709c6a52844f6a69ff Mon Sep 17 00:00:00 2001 From: Kurt Jacobson Date: Wed, 15 Jul 2026 21:26:41 -0400 Subject: [PATCH 06/12] Show "Perpetual" instead of a dash for licenses with no expiry --- web/src/i18n/locales/en.ts | 1 + web/src/i18n/locales/zh.ts | 1 + web/src/pages/admin/licenses.tsx | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 03c4a63..752fc8e 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -125,6 +125,7 @@ const en = { "licenses.issue": "Issue License", "licenses.licenseKey": "License Key", "licenses.validUntil": "Valid Until", + "licenses.perpetual": "Perpetual", "licenses.validUntilOptional": "Valid until (optional)", "licenses.validUntilHint": "Leave empty for a perpetual license (trials still use the plan's trial days).", "licenses.validUntilEdit": "Edit expiry", diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 2380248..7bff0f8 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -123,6 +123,7 @@ const zh = { "licenses.issue": "签发许可证", "licenses.licenseKey": "许可证密钥", "licenses.validUntil": "有效期至", + "licenses.perpetual": "永久", "licenses.validUntilOptional": "有效期至(可选)", "licenses.validUntilHint": "留空表示永久许可证(试用版仍按套餐的试用天数计算)。", "licenses.validUntilEdit": "编辑到期时间", diff --git a/web/src/pages/admin/licenses.tsx b/web/src/pages/admin/licenses.tsx index 83d495d..9407603 100644 --- a/web/src/pages/admin/licenses.tsx +++ b/web/src/pages/admin/licenses.tsx @@ -216,7 +216,7 @@ export default function LicensesPage() { {t(`status.${lic.status}` as any)} - {formatDate(lic.valid_until)} + {lic.valid_until ? formatDate(lic.valid_until) : t("licenses.perpetual")} {formatDate(lic.created_at)} @@ -539,7 +539,7 @@ function LicenseDetail({ id, onClose }: { id: string; onClose: () => void }) {

{t("licenses.validUntil")}

{editingValidUntil === null ? (
-

{formatDate(lic.valid_until)}

+

{lic.valid_until ? formatDate(lic.valid_until) : t("licenses.perpetual")}

{/* Stripe owns the expiry for subscription licenses — editing it here would be overwritten on renewal. */} {lic.payment_provider !== "stripe" && ( From 2bd8cb5aa296f92d3fd671072f7b7e295ab1aa24 Mon Sep 17 00:00:00 2001 From: Kurt Jacobson Date: Fri, 17 Jul 2026 12:26:52 -0400 Subject: [PATCH 07/12] Fix custom-logo favicon: update every , not just the first index.html ships two icon links (svg + sizes=32x32) and browsers often prefer the sized one, so rewriting only the first link never visibly changed the tab icon. --- web/src/hooks/use-site-config.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/web/src/hooks/use-site-config.tsx b/web/src/hooks/use-site-config.tsx index 091a98b..4611887 100644 --- a/web/src/hooks/use-site-config.tsx +++ b/web/src/hooks/use-site-config.tsx @@ -42,10 +42,15 @@ export function SiteConfigProvider({ children }: { children: ReactNode }) { attribution_url: data.attribution_url || "https://keygate.app", loading: false, }) - // Dynamic favicon from custom logo + // Dynamic favicon from custom logo. index.html declares + // multiple variants and browsers pick their + // favorite (often the sizes="32x32" one), so rewriting only + // the first link never visibly changed the tab icon — update + // them all. if (data.logo_url) { - const link = document.querySelector("link[rel='icon']") as HTMLLinkElement - if (link) link.href = data.logo_url + document.querySelectorAll("link[rel~='icon']").forEach((link) => { + link.href = data.logo_url + }) } if (data.brand_color) { document.documentElement.style.setProperty("--color-primary", data.brand_color) From 7851e6e0744d6fc7a85625e7505bad07d0cbedfa Mon Sep 17 00:00:00 2001 From: Steven Date: Sat, 8 Aug 2026 19:16:03 +0900 Subject: [PATCH 08/12] Fix setup wizard 500: include checkout_id in default plan insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 20260401_plan_checkout_id made plans.checkout_id NOT NULL, and store.CreatePlan fills it via shortID() — but the setup wizard's raw INSERT was never updated, so POST /api/v1/setup/initialize fails with a not-null violation on every fresh install (surfaced to the client as an opaque 500 INTERNAL_ERROR). Export the existing shortID() helper as store.ShortID() (same pattern as store.NewID(), which was exported for this handler) and include checkout_id in the wizard's plan INSERT. Co-Authored-By: Claude Fable 5 --- internal/handler/setup.go | 4 ++-- internal/store/admin.go | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/handler/setup.go b/internal/handler/setup.go index ac032bd..cd8cebd 100644 --- a/internal/handler/setup.go +++ b/internal/handler/setup.go @@ -173,8 +173,8 @@ func (h *SetupHandler) Initialize(c *gin.Context) { maxActivations, maxSeats = 3, 10 } if _, err := tx.NewRaw( - "INSERT INTO plans (id, product_id, name, slug, license_type, max_activations, max_seats, grace_days, active, created_at) VALUES (?, ?, 'Pro', 'pro', 'subscription', ?, ?, 7, true, now())", - planID, productID, maxActivations, maxSeats, + "INSERT INTO plans (id, product_id, name, slug, license_type, max_activations, max_seats, grace_days, active, checkout_id, created_at) VALUES (?, ?, 'Pro', 'pro', 'subscription', ?, ?, 7, true, ?, now())", + planID, productID, maxActivations, maxSeats, store.ShortID(), ).Exec(ctx); err != nil { response.Internal(c) return diff --git a/internal/store/admin.go b/internal/store/admin.go index 9d84646..ff2b4fa 100644 --- a/internal/store/admin.go +++ b/internal/store/admin.go @@ -78,6 +78,10 @@ func shortID() string { return hex.EncodeToString(b)[:8] } +// ShortID generates a URL-safe 8-character unique ID for checkout links. +// Exported for use by the setup handler. +func ShortID() string { return shortID() } + func (s *Store) UpdatePlan(ctx context.Context, p *model.Plan) error { _, err := s.DB.NewUpdate().Model(p).WherePK().Exec(ctx) return err From 76c5e2baa312ada076a15431c74761c7b90b4c20 Mon Sep 17 00:00:00 2001 From: Steven Date: Sat, 8 Aug 2026 19:16:03 +0900 Subject: [PATCH 09/12] Fix docker-compose postgres volume mount for postgres 18 images postgres:18+ official images moved the data directory into a versioned subdirectory and expect the volume to be mounted at /var/lib/postgresql instead of /var/lib/postgresql/data. With the old mount point a fresh `docker compose up` fails: the postgres container loops with "There appears to be PostgreSQL data in /var/lib/postgresql/data (unused mount/volume)" and never becomes healthy, so the keygate container never starts. See https://github.com/docker-library/postgres/issues/37 Co-Authored-By: Claude Fable 5 --- docker-compose.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index c512247..e9e8537 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,10 @@ services: POSTGRES_PASSWORD: keygate POSTGRES_DB: keygate volumes: - - pgdata:/var/lib/postgresql/data + # postgres:18+ images expect the volume mounted at /var/lib/postgresql + # (data lives in a versioned subdirectory); mounting .../data makes + # initdb fail on a fresh volume. https://github.com/docker-library/postgres/issues/37 + - pgdata:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U keygate"] interval: 10s From 9752f93a33468636aaca247bc9435dbae6fe888e Mon Sep 17 00:00:00 2001 From: Alan Liu Date: Sat, 15 Aug 2026 09:17:56 +0800 Subject: [PATCH 10/12] Harden license expiry endpoint: reject Stripe-billed licenses and past dates, emit webhook --- internal/handler/admin.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index c52b245..7e623f2 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -1112,6 +1112,17 @@ func (h *AdminHandler) SetLicenseValidUntil(c *gin.Context) { return } + // Stripe owns the expiry on billed licenses — the next renewal + // webhook overwrites whatever we set here, so accepting the edit + // would look like it worked and then silently revert. The dashboard + // hides the control; this closes the same door on the API, which + // licenses:write API keys also reach. + if lic.PaymentProvider == "stripe" { + response.Conflict(c, "STRIPE_MANAGED", + "expiry for Stripe-billed licenses is managed by the subscription", nil) + return + } + var validUntil *time.Time if req.ValidUntil != "" { ts, err := time.Parse(time.RFC3339, req.ValidUntil) @@ -1119,6 +1130,14 @@ func (h *AdminHandler) SetLicenseValidUntil(c *gin.Context) { response.BadRequest(c, "valid_until must be an RFC 3339 timestamp or empty") return } + // Same rule as CreateLicense. Back-dating is not an "expire now" + // shortcut: the grace-expiry sweep would pick the license up and + // email the customer that it expired, so a mistyped year turns + // into customer-facing mail. Use revoke/suspend to end a license. + if !ts.After(time.Now()) { + response.BadRequest(c, "valid_until must be in the future") + return + } validUntil = &ts } @@ -1133,6 +1152,15 @@ func (h *AdminHandler) SetLicenseValidUntil(c *gin.Context) { ActorType: "admin", ActorID: adminID(c), Changes: map[string]any{"valid_until": req.ValidUntil}, }) + if h.Webhook != nil { + // null valid_until means perpetual — send it explicitly so an + // integration can tell "cleared" apart from "field omitted". + payload := map[string]any{"license_id": id, "email": lic.Email, "valid_until": nil} + if validUntil != nil { + payload["valid_until"] = validUntil.Format(time.RFC3339) + } + h.Webhook.Dispatch(c, lic.ProductID, "license.expiry_changed", payload) + } response.OK(c, lic) } From b7fb8617ea96a1583769e640e37fae3cc9717835 Mon Sep 17 00:00:00 2001 From: Alan Liu Date: Sat, 15 Aug 2026 09:17:56 +0800 Subject: [PATCH 11/12] List every dispatched license event as a subscribable webhook --- web/src/pages/admin/webhooks.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/src/pages/admin/webhooks.tsx b/web/src/pages/admin/webhooks.tsx index 8f9c9c6..bacb80e 100644 --- a/web/src/pages/admin/webhooks.tsx +++ b/web/src/pages/admin/webhooks.tsx @@ -46,12 +46,22 @@ import { useI18n } from "@/i18n" import { admin, type WebhookConfig } from "@/lib/api" import { boolColor, formatDate } from "@/lib/utils" +// Must stay in sync with the events the backend actually dispatches — +// Dispatch only delivers to webhooks subscribed to the event, so an +// event missing from this list can never be subscribed to and is +// effectively undeliverable for anyone configuring from the dashboard. const WEBHOOK_EVENTS = [ "license.created", + "license.activated", + "license.deactivated", + "license.expiry_changed", + "license.expired", "license.canceled", "license.suspended", "license.reinstated", "license.revoked", + "license.payment_failed", + "license.payment_recovered", "quota.warning", "quota.exceeded", "seat.added", From 272ca86d19c29614b141977043ca97733edbf904 Mon Sep 17 00:00:00 2001 From: Alan Liu Date: Sat, 15 Aug 2026 09:17:56 +0800 Subject: [PATCH 12/12] Warn that a new expiry date alone won't revive an expired license --- web/src/i18n/locales/en.ts | 2 ++ web/src/i18n/locales/zh.ts | 1 + web/src/pages/admin/licenses.tsx | 14 ++++++++------ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 752fc8e..422b1ba 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -130,6 +130,8 @@ const en = { "licenses.validUntilHint": "Leave empty for a perpetual license (trials still use the plan's trial days).", "licenses.validUntilEdit": "Edit expiry", "licenses.validUntilClear": "Leave empty to make the license perpetual.", + "licenses.validUntilExpiredHint": + "This license is expired — a new date alone won't reactivate it. Use Reinstate as well.", "licenses.empty": "No licenses found", "licenses.noProducts": "No products yet", "licenses.noProductsDesc": "Create a product and plan first before issuing licenses.", diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 7bff0f8..d69af72 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -128,6 +128,7 @@ const zh = { "licenses.validUntilHint": "留空表示永久许可证(试用版仍按套餐的试用天数计算)。", "licenses.validUntilEdit": "编辑到期时间", "licenses.validUntilClear": "留空表示永久许可证。", + "licenses.validUntilExpiredHint": "此许可证已过期 — 仅修改日期不会重新激活,还需执行「恢复」。", "licenses.empty": "未找到许可证", "licenses.noProducts": "暂无产品", "licenses.noProductsDesc": "请先创建产品和方案,然后再签发许可证。", diff --git a/web/src/pages/admin/licenses.tsx b/web/src/pages/admin/licenses.tsx index 9407603..35ee54c 100644 --- a/web/src/pages/admin/licenses.tsx +++ b/web/src/pages/admin/licenses.tsx @@ -573,16 +573,18 @@ function LicenseDetail({ id, onClose }: { id: string; onClose: () => void }) { > {t("common.save")} -

{t("licenses.validUntilClear")}

+ {/* An expired license stays dead no matter what date + is set — assertUsable short-circuits on the status + before it ever reads valid_until. Say so, or the + admin walks away thinking the edit revived it. */} + {lic.status === "expired" && ( +

{t("licenses.validUntilExpiredHint")}

+ )}
)}