Skip to content

Commit a9d2179

Browse files
committed
fix(0.17.1): static lease reaper missed same-day expirations
The static lease manager wrote expires_at via toISOString() ('2026-05-15T10:00:00.000Z') but compared with SQLite's datetime('now') ('2026-05-15 10:00:00'). At position 10 of those two strings, 'T' (0x54) lex-compares greater than ' ' (0x20), so ANY approved lease whose expires_at fell on the same UTC calendar day as the current moment lex-compared as still-in-the-future. Effect: - listActive() kept expired-today rows in the Active Leases page. - reapExpired() never revoked them; they sat with revoked=0 until the UTC date rolled over. Audit confirmed: zero lease.reap events in our retained history. Dynamic leases already converted to SQLite format on write (dynamic/manager.ts:357) and were unaffected. Fix: every static-lease WHERE that compared expires_at or request_expires_at to datetime('now') now uses strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), which produces the byte-identical format toISOString emits. No DB migration needed (stored format is unchanged). No API or wire change. Existing test masked the bug by manually setting expires_at via SQLite's space-separated datetime() instead of going through the production write path. Test now writes via toISOString() like production does. Added a regression test specifically for the same-day expiration case. 568/568 tests pass.
1 parent 8442325 commit a9d2179

4 files changed

Lines changed: 46 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ release notes (built from conventional-commit subjects between tags) carry the
55
fine-grained per-commit log. This file summarises each release at a higher
66
level.
77

8+
## [0.17.1] - 2026-05-15
9+
10+
### Fix
11+
12+
- **Static lease reaper now catches same-day expirations.** The static lease manager wrote `expires_at` via JavaScript's `toISOString()` (`'2026-05-15T10:00:00.000Z'`) but compared it in SQLite against `datetime('now')` (`'2026-05-15 10:00:00'`). At character position 10 of those two strings, `'T'` (0x54) is lexicographically greater than `' '` (0x20), so any approved lease whose `expires_at` falls on the same UTC calendar day as the current moment lex-compared as still-in-the-future. Effect: `listActive()` kept showing those rows on the Active Leases page after they expired, and `reapExpired()` never revoked them until the UTC date rolled over. The dynamic lease manager already converted to SQLite format before storing and was unaffected. Fix: every static-lease WHERE clause that compared `expires_at` or `request_expires_at` to `datetime('now')` now uses `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, which produces the byte-identical format `toISOString()` emits. No DB migration required (the stored format is unchanged). No API/wire format change.
13+
- Test fix: `test("reapExpired marks expired leases as revoked")` was manually overwriting `expires_at` with SQLite's space-separated format via `datetime('now', '-1 minute')`, bypassing the production write path and masking the bug. Test now writes via `new Date(...).toISOString()` matching how production writes. Added a regression test specifically for the same-day expiration case.
14+
815
## [0.17.0] - 2026-05-15
916

1017
### Feature

src/lease/manager.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ import type { AuditLog } from "../audit/logger";
55
import type { EventBus } from "../events/bus";
66
import { sendLeaseRequestWebhook } from "./webhook";
77

8+
// SQLite expression that yields the current UTC time in the SAME ISO format
9+
// `Date.prototype.toISOString()` produces ('YYYY-MM-DDTHH:MM:SS.fffZ'). Used
10+
// for every comparison against `expires_at` / `request_expires_at` so the
11+
// lex compare matches chronological order. Using bare `datetime('now')`
12+
// returns 'YYYY-MM-DD HH:MM:SS' (space separator) which lex-compares LESS
13+
// than any ISO-format string at the date/time boundary, silently breaking
14+
// same-day expiry checks.
15+
const SQL_NOW_ISO = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
16+
817
export type LeaseStatus = "pending" | "approved" | "denied" | "expired";
918

1019
export interface Lease {
@@ -196,7 +205,7 @@ export class LeaseManager {
196205
.query(
197206
`SELECT * FROM leases
198207
WHERE identity = ? AND secret_path = ? AND status = 'pending'
199-
AND revoked = 0 AND request_expires_at > datetime('now')`
208+
AND revoked = 0 AND request_expires_at > ${SQL_NOW_ISO}`
200209
)
201210
.get(identity, secretPath) as LeaseRow | null;
202211
if (existing) return rowToLease(existing);
@@ -434,7 +443,7 @@ export class LeaseManager {
434443

435444
listPending(identity?: string): Lease[] {
436445
let query =
437-
"SELECT * FROM leases WHERE status = 'pending' AND revoked = 0 AND request_expires_at > datetime('now')";
446+
`SELECT * FROM leases WHERE status = 'pending' AND revoked = 0 AND request_expires_at > ${SQL_NOW_ISO}`;
438447
const params: string[] = [];
439448
if (identity) {
440449
query += " AND identity = ?";
@@ -453,7 +462,7 @@ export class LeaseManager {
453462
.query(
454463
`SELECT * FROM leases
455464
WHERE identity = ? AND secret_path = ? AND status = 'approved'
456-
AND revoked = 0 AND expires_at > datetime('now')
465+
AND revoked = 0 AND expires_at > ${SQL_NOW_ISO}
457466
ORDER BY expires_at DESC LIMIT 1`
458467
)
459468
.get(identity, secretPath) as LeaseRow | null;
@@ -554,7 +563,7 @@ export class LeaseManager {
554563

555564
listActive(identity?: string): Lease[] {
556565
let query =
557-
"SELECT * FROM leases WHERE revoked = 0 AND status = 'approved' AND expires_at > datetime('now')";
566+
`SELECT * FROM leases WHERE revoked = 0 AND status = 'approved' AND expires_at > ${SQL_NOW_ISO}`;
558567
const params: string[] = [];
559568

560569
if (identity) {
@@ -576,7 +585,7 @@ export class LeaseManager {
576585
// Approved past expiry: existing semantics (revoke).
577586
const approvedExpired = this.db
578587
.query(
579-
"UPDATE leases SET revoked = 1 WHERE revoked = 0 AND status = 'approved' AND expires_at <= datetime('now')"
588+
`UPDATE leases SET revoked = 1 WHERE revoked = 0 AND status = 'approved' AND expires_at <= ${SQL_NOW_ISO}`
580589
)
581590
.run();
582591

@@ -585,7 +594,7 @@ export class LeaseManager {
585594
.query(
586595
`SELECT id, identity, secret_path FROM leases
587596
WHERE revoked = 0 AND status = 'pending'
588-
AND request_expires_at IS NOT NULL AND request_expires_at <= datetime('now')`
597+
AND request_expires_at IS NOT NULL AND request_expires_at <= ${SQL_NOW_ISO}`
589598
)
590599
.all() as { id: string; identity: string; secret_path: string }[];
591600

src/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
// Single source of truth for the Gatehouse version.
22
// Update this value when cutting a release, then tag the commit.
3-
export const VERSION = "0.17.0";
3+
export const VERSION = "0.17.1";

test/lease.test.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,13 @@ describe("LeaseManager", () => {
108108
});
109109

110110
test("reapExpired marks expired leases as revoked", () => {
111-
// Create a lease with 1-second TTL
112111
const result = leases.checkout("test/key", "test-agent", 10)!;
113112

114-
// Manually set expires_at to the past
115-
db.query("UPDATE leases SET expires_at = datetime('now', '-1 minute') WHERE id = ?").run(
113+
// Set expires_at to one minute in the past, using the SAME format the
114+
// production write path uses (toISOString). This is what triggers the
115+
// lex-compare bug if the reaper's WHERE clause uses bare datetime('now').
116+
db.query("UPDATE leases SET expires_at = ? WHERE id = ?").run(
117+
new Date(Date.now() - 60_000).toISOString(),
116118
result.lease.id
117119
);
118120

@@ -123,6 +125,24 @@ describe("LeaseManager", () => {
123125
expect(lease!.revoked).toBe(true);
124126
});
125127

128+
test("reapExpired catches an approved lease that expired earlier today (regression for lex-compare bug)", () => {
129+
const result = leases.checkout("test/key", "test-agent", 10)!;
130+
131+
// Force the row to look exactly like a real production lease that
132+
// expired 5 seconds ago: same UTC calendar day as now, toISOString
133+
// format. Bare datetime('now') in the reaper would treat 'T' as
134+
// greater than ' ', leaving this row stuck in active.
135+
const fiveSecsAgo = new Date(Date.now() - 5_000).toISOString();
136+
db.query("UPDATE leases SET expires_at = ? WHERE id = ?").run(
137+
fiveSecsAgo,
138+
result.lease.id
139+
);
140+
141+
expect(leases.reapExpired()).toBe(1);
142+
expect(leases.getLease(result.lease.id)!.revoked).toBe(true);
143+
expect(leases.listActive().some(l => l.id === result.lease.id)).toBe(false);
144+
});
145+
126146
test("reapExpired returns 0 when no leases expired", () => {
127147
leases.checkout("test/key", "test-agent", 3600);
128148
expect(leases.reapExpired()).toBe(0);

0 commit comments

Comments
 (0)