Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitb9b9f22

feat(tokens): let PATs actually expire — add the expiration control

feat(tokens): let PATs actually expire — add the expiration control

api_tokens.expires_at has existed since the table was created and all three
auth paths (middleware/api-auth.ts, middleware/auth.ts, lib/git-push-auth.ts)
already honour it. Nothing ever wrote to it: the create form had no expiry
control and POST /settings/tokens never set the column, so every PAT ever
issued was stored with expires_at = NULL — permanently valid, impossible to
rotate on a schedule. The emergency-PAT endpoint had the same gap.

- Add an Expiration field to the create form: 7 / 30 / 60 / 90 days, a custom
  date picker, or explicit "No expiration". Defaults to 30 days.
- parseExpiry() resolves the controls server-side. Custom dates anchor to
  23:59:59.999Z so "expires on the 15th" stays usable through the 15th in
  every timezone instead of dying at midnight UTC.
- Rejected input returns an INVALID_EXPIRY sentinel, never null. Conflating
  the two is how a refused date silently becomes an immortal token.
- A blank/absent expires_in (older client, scripted form post) defaults to
  30 days rather than to no-expiration.
- Show expiry in the token list — "Expires 26 Aug 2026", or a red
  "Expired 3 days ago" — and return expires_at from GET /api/user/tokens,
  which omitted it entirely.

Additive: existing tokens keep expires_at = NULL and continue to work. No
migration, nothing is retroactively expired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 27, 2026Parent: 4abf6f1
2 files changed+2350b9b9f22a2c110ecdeb63fc91e569f9e6cdb6ba86
2 changed files+235−0
Addedsrc/__tests__/token-expiry.test.ts+96−0View fileUnifiedSplit
1/**
2 * PAT expiration.
3 *
4 * The api_tokens.expires_at column has existed since the table was created,
5 * but nothing ever wrote to it: the create form had no expiry control and the
6 * POST handler never set the field, so every token ever issued was stored with
7 * expires_at = NULL. All three auth paths read that as "never expires", so
8 * PATs were effectively immortal and could not be rotated on a schedule.
9 *
10 * These cover the parsing rules and, crucially, that a rejected expiry never
11 * degrades into "no expiration".
12 */
13
14import { describe, expect, it } from "bun:test";
15import { parseExpiry, INVALID_EXPIRY } from "../routes/tokens";
16
17const NOW = new Date("2026-07-27T09:00:00.000Z");
18const DAY = 86_400_000;
19
20describe("parseExpiry — presets", () => {
21 for (const days of [7, 30, 60, 90]) {
22 it(`"${days}" → ${days} days from now`, () => {
23 const got = parseExpiry(String(days), "", NOW);
24 expect(got).toBeInstanceOf(Date);
25 expect((got as Date).getTime()).toBe(NOW.getTime() + days * DAY);
26 });
27 }
28
29 it('"never" → null (no expiration)', () => {
30 expect(parseExpiry("never", "", NOW)).toBeNull();
31 });
32
33 it("a blank choice defaults to 30 days, NOT to no-expiration", () => {
34 const got = parseExpiry("", "", NOW);
35 expect(got).toBeInstanceOf(Date);
36 expect((got as Date).getTime()).toBe(NOW.getTime() + 30 * DAY);
37 });
38});
39
40describe("parseExpiry — custom date", () => {
41 it("accepts a future yyyy-mm-dd and anchors to end-of-day UTC", () => {
42 const got = parseExpiry("custom", "2026-08-15", NOW);
43 expect(got).toBeInstanceOf(Date);
44 expect((got as Date).toISOString()).toBe("2026-08-15T23:59:59.999Z");
45 });
46
47 it("end-of-day anchoring keeps today's date usable, not instantly dead", () => {
48 // A user picking "today" should get the rest of the day, not a token that
49 // expired at 00:00 this morning.
50 const got = parseExpiry("custom", "2026-07-27", NOW);
51 expect(got).toBeInstanceOf(Date);
52 expect((got as Date).getTime()).toBeGreaterThan(NOW.getTime());
53 });
54
55 it("rejects a past date", () => {
56 expect(parseExpiry("custom", "2026-07-01", NOW)).toBe(INVALID_EXPIRY);
57 });
58
59 it("rejects a blank custom date", () => {
60 expect(parseExpiry("custom", "", NOW)).toBe(INVALID_EXPIRY);
61 });
62
63 it("rejects garbage", () => {
64 expect(parseExpiry("custom", "not-a-date", NOW)).toBe(INVALID_EXPIRY);
65 });
66});
67
68describe("parseExpiry — rejected input never becomes 'no expiration'", () => {
69 // The whole point of the INVALID_EXPIRY sentinel. If these returned null the
70 // caller would happily mint an immortal token from input it had rejected.
71 for (const bad of ["0", "-30", "1.5", "abc"]) {
72 it(`"${bad}" is rejected, not treated as never-expires`, () => {
73 const got = parseExpiry(bad, "", NOW);
74 expect(got).toBe(INVALID_EXPIRY);
75 expect(got).not.toBeNull();
76 });
77 }
78});
79
80describe("expiry enforcement contract", () => {
81 it("all three auth paths null-guard expiresAt before comparing", async () => {
82 // Guards against a regression where someone drops the `row.expiresAt &&`
83 // check: `new Date(null) < new Date()` is 1970 < now === true, which would
84 // instantly reject every no-expiration token in existence.
85 const { readFileSync } = await import("fs");
86 const paths = [
87 "src/middleware/api-auth.ts",
88 "src/middleware/auth.ts",
89 "src/lib/git-push-auth.ts",
90 ];
91 for (const p of paths) {
92 const src = readFileSync(p, "utf8");
93 expect(src).toContain("expiresAt &&");
94 }
95 });
96});
Modifiedsrc/routes/tokens.tsx+139−0View fileUnifiedSplit
583583}
584584
585585/** Format a date as a friendly relative time, with absolute fallback. */
586/**
587 * Absolute date for a future expiry ("Expires 26 Aug 2026"). Relative phrasing
588 * is fine for the past ("Expired 3 days ago") but vague forwards — "expires in
589 * 2 months" is not something you can plan a rotation around.
590 */
591function formatExpiry(date: Date | string): string {
592 const d = typeof date === "string" ? new Date(date) : date;
593 if (Number.isNaN(d.getTime())) return "";
594 return d.toLocaleDateString("en-GB", {
595 day: "numeric",
596 month: "short",
597 year: "numeric",
598 timeZone: "UTC",
599 });
600}
601
586602function relativeTime(date: Date | string | null | undefined): string {
587603 if (!date) return "";
588604 const d = typeof date === "string" ? new Date(date) : date;
762778 <span class="tokens-meta-time">Never used</span>
763779 </>
764780 )}
781 <span class="tokens-meta-sep">·</span>
782 {!token.expiresAt ? (
783 <span class="tokens-meta-time">No expiration</span>
784 ) : new Date(token.expiresAt) < new Date() ? (
785 <span
786 class="tokens-meta-time"
787 style="color: var(--red); font-weight: 600;"
788 >
789 Expired {relativeTime(token.expiresAt)}
790 </span>
791 ) : (
792 <span class="tokens-meta-time">
793 Expires {formatExpiry(token.expiresAt)}
794 </span>
795 )}
765796 </div>
766797 </div>
767798 <div class="tokens-card-action">
838869 via the MCP server.
839870 </div>
840871 </div>
872 <div class="tokens-field">
873 <label class="tokens-field-label" for="expires_in">
874 Expiration
875 </label>
876 <select id="expires_in" name="expires_in" class="tokens-input">
877 <option value="30" selected>
878 30 days
879 </option>
880 <option value="7">7 days</option>
881 <option value="60">60 days</option>
882 <option value="90">90 days</option>
883 <option value="custom">Custom…</option>
884 <option value="never">No expiration</option>
885 </select>
886 <input
887 type="date"
888 id="expires_at"
889 name="expires_at"
890 class="tokens-input"
891 style="display: none; margin-top: 8px;"
892 aria-label="Custom expiration date"
893 />
894 <div class="tokens-field-hint">
895 An expiring token limits the damage if it leaks. Pick{" "}
896 <strong>No expiration</strong> only for long-lived automation
897 you actively monitor.
898 </div>
899 </div>
841900 <button type="submit" class="tokens-submit">
842901 Generate token
843902 </button>
844903 </form>
904 {/* Reveal the date picker only for the "Custom…" choice. Progressive
905 enhancement: with JS off the select still submits a valid preset,
906 and the server ignores an empty expires_at. */}
907 <script
908 dangerouslySetInnerHTML={{
909 __html: `(function(){
910 var sel = document.getElementById('expires_in');
911 var day = document.getElementById('expires_at');
912 if (!sel || !day) return;
913 function sync(){
914 var custom = sel.value === 'custom';
915 day.style.display = custom ? 'block' : 'none';
916 day.required = custom;
917 if (custom && !day.value) {
918 var d = new Date(Date.now() + 30 * 86400000);
919 day.min = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
920 day.value = d.toISOString().slice(0, 10);
921 }
922 }
923 sel.addEventListener('change', sync);
924 sync();
925})();`,
926 }}
927 />
845928 </div>
846929 </div>
847930 </div>
851934});
852935
853936// Create token
937/**
938 * Sentinel for "the user asked for an expiry we can't honour". Distinct from
939 * `null`, which legitimately means "no expiration" — conflating the two is how
940 * a rejected date would silently become a token that never expires.
941 */
942export const INVALID_EXPIRY = Symbol("invalid-expiry");
943
944/**
945 * Resolve the create-token form's expiry controls to a Date (or null for
946 * "no expiration").
947 *
948 * `expires_in` is a preset in days, the literal "never", or "custom" — in
949 * which case `expires_at` carries a yyyy-mm-dd from the date picker. An
950 * absent/blank `expires_in` means the request predates this field (e.g. an
951 * older client or a script posting the form); those default to 30 days rather
952 * than to a token that lives forever.
953 */
954export function parseExpiry(
955 expiresIn: string,
956 expiresAtRaw: string,
957 now: Date = new Date()
958): Date | null | typeof INVALID_EXPIRY {
959 const choice = expiresIn.trim() || "30";
960
961 if (choice === "never") return null;
962
963 if (choice === "custom") {
964 const raw = expiresAtRaw.trim();
965 if (!raw) return INVALID_EXPIRY;
966 // Anchor to end-of-day UTC so "expires on the 5th" stays usable through
967 // the 5th in every timezone, rather than dying at 00:00 UTC.
968 const parsed = new Date(`${raw}T23:59:59.999Z`);
969 if (Number.isNaN(parsed.getTime())) return INVALID_EXPIRY;
970 if (parsed.getTime() <= now.getTime()) return INVALID_EXPIRY;
971 return parsed;
972 }
973
974 const days = Number(choice);
975 if (!Number.isFinite(days) || !Number.isInteger(days) || days <= 0) {
976 return INVALID_EXPIRY;
977 }
978 return new Date(now.getTime() + days * 86_400_000);
979}
980
854981tokens.post("/settings/tokens", async (c) => {
855982 const user = c.get("user")!;
856983 const body = await c.req.parseBody();
868995 return c.redirect("/settings/tokens?error=Name+is+required");
869996 }
870997
998 const expiresAt = parseExpiry(
999 String(body.expires_in ?? ""),
1000 String(body.expires_at ?? "")
1001 );
1002 if (expiresAt === INVALID_EXPIRY) {
1003 return c.redirect(
1004 "/settings/tokens?error=Expiration+must+be+a+future+date"
1005 );
1006 }
1007
8711008 const token = generateToken();
8721009 const tokenH = await hashToken(token);
8731010
8771014 tokenHash: tokenH,
8781015 tokenPrefix: token.slice(0, 12),
8791016 scopes,
1017 expiresAt,
8801018 });
8811019
8821020 return c.redirect(
10291167 scopes: apiTokens.scopes,
10301168 lastUsedAt: apiTokens.lastUsedAt,
10311169 createdAt: apiTokens.createdAt,
1170 expiresAt: apiTokens.expiresAt,
10321171 })
10331172 .from(apiTokens)
10341173 .where(eq(apiTokens.userId, user.id));
10351174