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

  1. Use null for a non-expiring token, 0 for immediate expiry, or a positive number of seconds (86400 = 1 day is the default).
  2. Clamp computed TTLs: `Math.max(0, ttl)` before passing to createToken.
  3. 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

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


AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12). Data as JSON: /api/errors/46216ef12dbb17bc. Report an issue: GitHub.