Skip to content

Feature/license valid until - #6

Merged
alanisme merged 6 commits into
tabloy:mainfrom
KurtJacobson:feature/license-valid-until
Aug 15, 2026
Merged

Feature/license valid until#6
alanisme merged 6 commits into
tabloy:mainfrom
KurtJacobson:feature/license-valid-until

Conversation

@KurtJacobson

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds the ability to set, edit, and clear an expiration date on manually issued licenses:

  • POST /admin/licenses accepts an optional valid_until (RFC 3339, must be in the future). Empty preserves existing behavior: trial plans default to now + trial_days, everything else is perpetual. An explicit value takes precedence over the trial default.

  • New endpoint POST /admin/licenses/:id/valid-until sets or clears the expiry on an existing license (empty = perpetual). It deliberately does not touch license status — re-arming an expired license remains an explicit /reinstate action, so a date edit can't silently reactivate a revoked customer. Changes are audit-logged.

  • Admin UI: optional "Valid until" date field in the issue dialog, and an edit control on the detail view (hidden for Stripe-billed licenses, whose expiry is renewal-managed). Dates mean end-of-day 23:59:59 in the admin's local timezone. Licenses without an expiry now display "Perpetual" instead of a dash. English and Chinese strings included.

Existing expiry enforcement (internal/service/expiry.go) is untouched - this only adds ways to set the date it already honors.

Why?

valid_until is currently only set automatically (trial plans via trial_days, subscriptions via Stripe renewals), so fixed-term manual licensing — e.g. an invoice-based one-year license - isn't possible. This makes easy in the API and the admin dashboard.

How to test

  1. Admin > Licenses > Issue License: pick a plan, set "Valid until" to a future date > the license shows that date (23:59 local) in the table and detail view.
  2. Issue a license with the field empty > shows "Perpetual".
  3. In the detail view, click the pencil next to Valid Until > change the date > Save > date updates. Clear the field and save > back to "Perpetual".
  4. Verify the edit control does not appear on a Stripe-billed license.
  5. API: POST /admin/licenses with valid_until: "2020-01-01T00:00:00Z" > 400 (past date); malformed timestamp > 400.
  6. Confirm /license/verify returns 404 for a license who's valid_until has passed (existing enforcement).

Checklist

  • go vet ./... passes
  • go test ./... passes
  • Frontend builds (cd web && bun run build)
  • Tested manually in browser

Screenshots

image image

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.
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.
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.
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.
@pjz

pjz commented Jul 30, 2026

Copy link
Copy Markdown

I was about to add something like this myself, as it's half of what I need to support perpetual-fallback licensing. I'll submit a PR for the other half (putting the expiry date in the license) after this lands.

@KurtJacobson

Copy link
Copy Markdown
Contributor Author

@pjz There does not seem to be any active development on this repo, at least it's been a few weeks since I submitted this PR with no comments or feedback. It's a great project, hopefully it does not die. I have several other PRs I'd like to make. We shall see!

@pjz

pjz commented Aug 1, 2026

Copy link
Copy Markdown

@KurtJacobson If you make a public fork of this repo with your features, I'd likely base my license server off of it - I need this feature and one other (putting the expiry date into the signed license handed to the app) to be able to nicely implement a permanent-fallback licensing scheme, which is what I want to use.

@KurtJacobson

KurtJacobson commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

@pjz I actually already have "JetBrains" style permanent-fallback licensing implemented, is this what you are looking for?

I call it "Perpetual + paid support". This scheme gates updates on a "support period" that's independent of the license itself. The license never expires, but the paid support/updates period does. Customers retain access to any release published while their support was active, but have to be current on their support to get updates.

Here is more info: https://kurtjacobson.github.io/keygate/concepts/support-window/

And my fork is public: https://github.com/kurtjacobson/keygate

Docker images are available: docker pull ghcr.io/kurtjacobson/keygate:main

@alanisme

Copy link
Copy Markdown
Contributor

Sorry for the delay on this one.

Went through it properly — built it in a container and exercised the endpoints. Everything behaves as documented: an explicit date wins over the trial default, empty stays perpetual, past and malformed dates are rejected on create, and the edit endpoint sets and clears correctly with an audit entry each time.

Two follow-ups I'd like to do separately, neither blocking:

  • The edit endpoint accepts past dates while POST /admin/licenses rejects them. Not a hole — assertUsable compares valid_until regardless of status, so the license stops verifying immediately — just inconsistent between the two paths.
  • Extending an already-expired license doesn't revive it, which is the behavior you intended, but assertUsable short-circuits on StatusExpired before it ever looks at valid_until. So the UI ends up showing a future date on a license that stays dead. Worth a hint next to the field.

Happy to take the other PRs you mentioned.

@alanisme

Copy link
Copy Markdown
Contributor

Merged in #12.

I said I'd handle the two follow-ups separately, but ended up folding them in rather than leaving the endpoint half-guarded:

  • Stripe-billed licenses now get a 409. The dashboard already hid the control, but the endpoint is reachable by licenses:write API keys too, and the next renewal silently overwrites whatever gets set.
  • Past dates rejected, matching POST /admin/licenses. Back-dating isn't a safe "expire now" shortcut — the grace sweep picks the license up and emails the customer that it expired, so a mistyped year turns into customer-facing mail.
  • Added license.expiry_changed so the change isn't invisible to integrations, and a line in the UI noting that a new date alone won't revive an expired license.

One thing worth knowing for next time: the branch failed bun run lint on the cancel button in licenses.tsx — biome wanted the props on one line. Fixed on merge, nothing for you to do, but your checklist covered vet/test/build and not lint, and CI runs it.

Good PR. The timezone round-trip in endOfDayISO/localDateValue and keeping the date edit separate from status were both the right calls.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants