garrytan/gstack · error · Error
expiresSeconds must be >= 0 or null
Error message
expiresSeconds must be >= 0 or null
What it means
Thrown by createToken when opts.expiresSeconds is a negative number. The field accepts a positive integer (seconds until expiry), 0 (expired immediately), or null (never expires). Negative TTLs are refused because they would mint a token whose expiry is in the past without the caller realizing.
Source
Thrown at browse/src/token-registry.ts:211
const {
clientId,
scopes = ['read', 'write'],
domains,
tabPolicy = 'own-only',
rateLimit = 10,
expiresSeconds = 86400, // 24h default
} = opts;
// Validate inputs
const validScopes: ScopeCategory[] = ['read', 'write', 'admin', 'meta', 'control'];
for (const s of scopes) {
if (!validScopes.includes(s as ScopeCategory)) {
throw new Error(`Invalid scope: ${s}. Valid: ${validScopes.join(', ')}`);
}
}
if (rateLimit < 0) throw new Error('rateLimit must be >= 0');
if (expiresSeconds !== null && expiresSeconds !== undefined && expiresSeconds < 0) {
throw new Error('expiresSeconds must be >= 0 or null');
}
const token = generateToken('gsk_sess_');
const now = new Date();
const expiresAt = expiresSeconds === null
? null
: new Date(now.getTime() + expiresSeconds * 1000).toISOString();
const info: TokenInfo = {
token,
clientId,
type: 'session',
scopes,
domains,
tabPolicy,
rateLimit,
expiresAt,
createdAt: now.toISOString(),View on GitHub (pinned to 94993f7401)
Solutions
- Use null for a non-expiring token, 0 for immediate expiry, or a positive number of seconds (86400 = 1 day is the default).
- Clamp computed TTLs: `Math.max(0, ttl)` before passing to createToken.
- If you intend 'mint expired to test revocation', call createToken with a positive value then revoke it instead.
Example fix
// before
createToken({ clientId: 'bot', expiresSeconds: remaining - buffer }); // can go negative
// after
createToken({ clientId: 'bot', expiresSeconds: Math.max(0, remaining - buffer) }); Defensive patterns
Strategy: validation
Validate before calling
function parseTtl(v: unknown): number | null {
if (v === null || v === undefined) return null;
const n = typeof v === 'string' ? Number(v) : v;
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) {
throw new Error('expiresSeconds must be >= 0 or null');
}
return Math.floor(n);
} Type guard
const isTtl = (v: unknown): v is number | null => v === null || (typeof v === 'number' && Number.isFinite(v) && v >= 0);
Try / catch
try {
return createToken({ clientId, expiresSeconds: ttl });
} catch (e: any) {
if (/expiresSeconds must be >= 0/.test(e.message)) {
return createToken({ clientId, expiresSeconds: Math.max(0, ttl) });
}
throw e;
} Prevention
- Clamp computed TTLs with Math.max(0, n) before minting.
- Use null for non-expiring tokens, not a magic negative number.
- Validate env-var TTLs with a schema that rejects negatives.
- Unit-test the renewal path that subtracts buffers from a budget.
When it happens
Trigger: Passing expiresSeconds: -60 explicitly; subtracting a renewal window from a budget that has already elapsed; coercing a malformed env var into a negative integer.
Common situations: A 'refresh' code path that computes `remaining - buffer` and the buffer exceeds remaining; migrating from a config that used negative values to mean 'already expired, force re-login'; timezone arithmetic producing a negative delta.
Related errors
- rateLimit must be >= 0
- token-registry already initialized with a different token; e
- Invalid scope: ${s}. Valid: ${validScopes.join(', ')}
- commitSkill: tier "${opts.tier}" has no resolved path.
- commitSkill: a skill named "${opts.name}" already exists at
AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12).
Data as JSON: /api/errors/46216ef12dbb17bc.
Report an issue: GitHub.