ttlSeconds = (int)$ttlSeconds; $dir = dirname($dbFile); if (!is_dir($dir)) { if (!mkdir($dir, 0700, true)) { throw new NextTokenException('Could not create token db dir: ' . $dir); } } $this->db = new PDO('sqlite:' . $dbFile); $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->db->exec('PRAGMA journal_mode = WAL'); $this->db->exec('PRAGMA busy_timeout = 5000'); $this->init(); } private function init() { $this->db->exec( 'CREATE TABLE IF NOT EXISTS next_tokens ( token TEXT PRIMARY KEY, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL )' ); $this->db->exec( 'CREATE INDEX IF NOT EXISTS idx_next_tokens_expires ON next_tokens(expires_at)' ); $this->db->exec( 'CREATE TABLE IF NOT EXISTS next_token_words ( word TEXT NOT NULL, kind TEXT NOT NULL, language TEXT NOT NULL, PRIMARY KEY(word, kind, language) )' ); $this->migrateTokenWordsTable(); $this->db->exec( 'CREATE INDEX IF NOT EXISTS idx_next_token_words_kind ON next_token_words(kind, language)' ); $this->seedWords(); } private function migrateTokenWordsTable() { $stmt = $this->db->query('PRAGMA table_info(next_token_words)'); $pkCols = array(); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { if ((int)$row['pk'] > 0) { $pkCols[(int)$row['pk']] = $row['name']; } } ksort($pkCols); if (array_values($pkCols) === array('word', 'kind', 'language')) { return; } $this->db->beginTransaction(); try { $this->db->exec( 'CREATE TABLE next_token_words_new ( word TEXT NOT NULL, kind TEXT NOT NULL, language TEXT NOT NULL, PRIMARY KEY(word, kind, language) )' ); $this->db->exec( 'INSERT OR IGNORE INTO next_token_words_new (word, kind, language) SELECT word, kind, language FROM next_token_words' ); $this->db->exec('DROP TABLE next_token_words'); $this->db->exec('ALTER TABLE next_token_words_new RENAME TO next_token_words'); $this->db->commit(); } catch (Throwable $e) { if ($this->db->inTransaction()) { $this->db->rollBack(); } throw $e; } } public function create($ttlSeconds = null) { $ttl = $ttlSeconds === null ? $this->ttlSeconds : (int)$ttlSeconds; if ($ttl <= 0) { throw new NextTokenException('Invalid ttl.'); } $this->cleanup(); $now = time(); $stmt = $this->db->prepare( 'INSERT INTO next_tokens (token, created_at, expires_at) VALUES (:token, :created_at, :expires_at)' ); for ($i = 0; $i < 25; $i++) { $token = $this->newToken(); try { $stmt->execute(array( ':token' => $token, ':created_at' => $now, ':expires_at' => $now + $ttl, )); return $token; } catch (PDOException $e) { if ($e->getCode() !== '23000') { throw $e; } } } throw new NextTokenException('Could not create unique next token.'); } public function isValid($token) { if (!is_string($token) || !preg_match('/^[a-z0-9-]{5,160}$/', $token)) { return false; } $stmt = $this->db->prepare( 'SELECT expires_at FROM next_tokens WHERE token = :token' ); $stmt->execute(array(':token' => $token)); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return false; } return time() <= (int)$row['expires_at']; } public function requireValid($token) { if (!$this->isValid($token)) { throw new NextTokenException('Invalid or expired next token.'); } return true; } public function cleanup() { $stmt = $this->db->prepare( 'DELETE FROM next_tokens WHERE expires_at < :now' ); $stmt->execute(array(':now' => time())); return $stmt->rowCount(); } public function currentTokens() { $stmt = $this->db->prepare( 'SELECT token, created_at, expires_at FROM next_tokens WHERE expires_at >= :now ORDER BY expires_at ASC, created_at ASC' ); $stmt->execute(array(':now' => time())); return $stmt->fetchAll(PDO::FETCH_ASSOC); } public function tokens() { $stmt = $this->db->query( 'SELECT token, created_at, expires_at FROM next_tokens ORDER BY expires_at ASC, created_at ASC' ); return $stmt->fetchAll(PDO::FETCH_ASSOC); } public function expired_html_response() { html_response('
This link is no longer valid. Please ask the human user for a new bootstrap link and restart the process from that link.
For the AI agent: do not try to invent a new next value.
Only follow links that appear on authorized HTML pages.