Skip to content
MeMikkoPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

103 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

time2live.xyz

Scheduling and liveness ("TTL") service for autonomous AI agents: cron and one-off HTTP webhook jobs (signed, retried) and heartbeat monitors — a dead man's switch that alerts when an agent stops pinging. Agents register with an EVM wallet and pay with x402, so there is no human account, no dashboard and no credit card in the loop. For agents holding funds there is also an on-chain dead man's switch contract on Base.

Live at https://time2live.xyz · API https://time2live.xyz/openapi.json · remote MCP server https://time2live.xyz/mcp.

Network mode is surfaced everywhere so agents and humans know whether money is real: the / summary carries network.mode (live on Base mainnet, test on a testnet, or disabled), and the landing page, /llms.txt, the dashboard and the OpenAPI description all label it. Mainnet is a config switch (X402_NETWORK=eip155:8453 with a mainnet facilitator; KEEPER_CHAIN_ID=8453); a mixed testnet/mainnet deployment is rejected at startup.

If you are an agent (or building one): start at /llms.txt for a compact guide, then Authentication and either Scheduled jobs or Heartbeat monitors. Prefer tools? See MCP server and discovery.

If you are running it: Local development below; docs/OPERATIONS.md is the full Hetzner deploy, backup, restore and monitoring runbook; docs/PLAN.md has the scope, architecture and design decisions.

Local development

Requirements: Node 22+, Docker (Docker Desktop on Windows, or WSL2). All commands below work in PowerShell, WSL and Git Bash.

cp .env.example .env            # PowerShell: Copy-Item .env.example .env
npm install
docker compose up -d postgres   # Postgres on 127.0.0.1:5432 (+ time2live_test database)
npm run migrate
npm run dev:api                 # http://localhost:3000/healthz
npm run dev:worker              # in another terminal

Full stack from the production image: docker compose --profile app up -d --build.

Command Purpose
npm test unit + integration tests (needs Postgres; override with TEST_DATABASE_URL)
npm run lint / npm run format ESLint / Prettier
npm run typecheck TypeScript without emitting
npm run db:generate -- --name <x> generate a SQL migration after editing src/core/db/schema.ts

Authentication (wallet sign-in)

Agents authenticate with an EVM wallet — no passwords or email:

  1. POST /v1/auth/challenge {"address":"0x…","chainId":8453} → an EIP-4361 (SIWE) message.
  2. Sign message verbatim with the wallet (EIP-191 personal_sign).
  3. POST /v1/auth/verify {"message":"…","signature":"0x…","keyName":"my-agent"} → apiKey.key (shown once; only its SHA-256 hash is stored). Use it as Authorization: Bearer t2l_….

Challenges are single-use and expire after 5 minutes. EOA signatures are verified offline; smart-contract wallets (ERC-1271/6492) require BASE_RPC_URL / BASE_SEPOLIA_RPC_URL. The OpenAPI document is served at /openapi.json.

Scheduled jobs

curl -X POST http://localhost:3000/v1/jobs -H "Authorization: Bearer $T2L_KEY" \
  -H 'Content-Type: application/json' -H 'Idempotency-Key: wake-1' -d '{
    "name": "wake agent",
    "schedule": { "type": "cron", "expression": "*/15 * * * *", "timezone": "Europe/Helsinki" },
    "target": { "url": "https://agent.example.com/wake", "method": "POST",
                "headers": { "Authorization": "Bearer my-agent-token" }, "body": { "task": "wake" } },
    "timeoutMs": 10000, "maxAttempts": 5 }'
  • Schedules: 5-field cron (+ @hourly/@daily/…, minimum interval 1 min) with an IANA timezone, or {"type":"once","at":"<ISO time>"}.
  • After downtime, missed occurrences are collapsed into a single catch-up run (the schedule then jumps to the next future slot) — never a replayed backlog. Add "freshnessSeconds": N (60 … 31536000) to drop even that catch-up when its slot is older than N seconds: the run is recorded skipped (not delivered, not charged). Omit it to deliver regardless of lateness. Clients can also gate on the T2L-Scheduled-For header.
  • Delivery: 2xx succeeds; timeouts, network errors, 408/425/429/5xx are retried with exponential backoff (10 s, 20 s, 40 s … ≤ 1 h, ±20 % jitter, Retry-After honoured); other statuses fail immediately. Every attempt is logged (GET /v1/runs/{id}); history is kept 30 days.
  • Targets: https on ports 443/8443 only. Private, loopback, link-local (incl. 169.254.169.254), CGNAT, ULA, NAT64 and our own IPs are refused — checked at creation and again at connect time after DNS resolution; every redirect hop (max 3) is re-validated and custom headers are dropped on cross-origin redirects.
  • Header values are stored encrypted and never returned.

Verifying deliveries

Each request carries T2L-Signature: t=<unix>,v1=<hex>, T2L-Delivery-Id (stable across retries — use it to deduplicate), T2L-Attempt, T2L-Event, T2L-Job-Id and T2L-Scheduled-For. Get your secret from GET /v1/account/webhook-secret.

import crypto from 'node:crypto';

function verify(secret, rawBody, header, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

Heartbeat monitors (dead man's switch)

# create (the response contains pingUrl)
curl -X POST http://localhost:3000/v1/monitors -H "Authorization: Bearer $T2L_KEY" \
  -H 'Content-Type: application/json' -d '{
    "name": "agent-7", "ttlSeconds": 300, "graceSeconds": 60,
    "alerts": { "webhookUrl": "https://ops.example.com/t2l-alerts", "telegram": true } }'

# ping from the agent (no API key; the unguessable id is the credential)
curl -fsS -X POST http://localhost:3000/v1/heartbeat/mon_…
  • States: new (never pinged, never alerts) → alive → dead when no ping arrives within ttlSeconds + graceSeconds → alive again on the next ping. paused suppresses alerts; resuming starts a fresh window.
  • Alerts: monitor.down and monitor.up (recovery) over up to four independent channels — a signed webhook (same T2L-Signature scheme as jobs, JSON body with the monitor snapshot), an optional secondary webhook for redundancy, a Telegram message, and email via an external provider (independent transport/infra, so one channel being down cannot silence the alert). Each channel is delivered and retried separately with backoff (6 attempts). Email requires the server to have RESEND_API_KEY set. Transitions are listed at GET /v1/monitors/{id}/events (30 days).
  • Telegram: POST /v1/account/telegram/link returns a t.me deep link; press Start in Telegram to link the chat, send /stop to unlink. Operators must set TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, TELEGRAM_WEBHOOK_SECRET and run admin telegram-webhook once.
  • Tiers: 1 monitor before activation, 3 after. Further monitors cost $0.25 per 30 days from credits (billing.plan = "paid"); when a renewal cannot be paid the monitor is paused and a monitor.unpaid alert is sent.

Billing (x402)

What Price
Activation (one-off) $0.10 — raises the free tier from 1 monitor + 50 runs/month to 3 + 100
Run beyond the monthly free $0.0005 from credits ($1 = 2,000 runs)
Monitor beyond the tier $0.25 per 30 days from credits
Credit packs $1, $5, $20

Payments use x402 v2 (USDC on Base; Base Sepolia while testing), so an agent pays without a human. Any call that needs money — POST /v1/monitors, POST /v1/monitors/{id}/resume, POST /v1/jobs/{id}/trigger, POST /v1/billing/activate, POST /v1/billing/credits — answers 402 with a PAYMENT-REQUIRED header (cheapest offer first; the body carries the same JSON plus error). Sign one offer with an x402 client and retry the same request with PAYMENT-SIGNATURE: the payment is settled, activation/credits applied and the request completes in one round trip; the receipt comes back in PAYMENT-RESPONSE. Any payment also activates the free tier. A settlement is credited at most once (unique transaction), and 402s are never stored under an Idempotency-Key, so retrying with the same key works.

import { wrapFetchWithPayment } from '@x402/fetch';
import { x402Client } from '@x402/core/client';
import { ExactEvmScheme } from '@x402/evm/exact/client';
import { privateKeyToAccount } from 'viem/accounts';

const client = new x402Client().register(
  'eip155:84532',
  new ExactEvmScheme(privateKeyToAccount(PK)),
);
const pay = wrapFetchWithPayment(fetch, client);
await pay('https://time2live.xyz/v1/billing/credits', {
  method: 'POST',
  headers: { authorization: `Bearer ${T2L_KEY}`, 'content-type': 'application/json' },
  body: JSON.stringify({ pack: 1 }),
});

x402 SDKs cap a single payment at $1 by default; raise maxAmountPerPayment for the $5/$20 packs. GET /v1/billing shows balance, tier and prices, GET /v1/billing/payments the settled payments. Scheduled runs that cannot be paid are recorded as skipped (the job keeps its schedule and resumes producing runs as soon as there is allowance or credit).

On-chain dead man's switch (Base)

For agents that hold funds, contracts/ contains DeadMansSwitchFactory + DeadMansSwitch (Foundry, OpenZeppelin v5).

  • One isolated clone per switch holds ETH and up to 20 ERC-20s.
  • The agent or owner pings it; after ttl without a ping, anyone can trigger() and everything goes to the beneficiary.
  • Once expired, the switch can't be revived or emptied by the owner.
  • Fee-on-transfer and rebasing tokens are handled.
  • Optional triggerRewardBps (≤ 5%) pays whoever calls trigger(), so a funded switch's liveness doesn't depend on our keeper — anyone (or an MEV bot) is incentivised to trigger it on time.
  • Tested with unit, fuzz and invariant tests, and checked with Slither.

The worker's keeper discovers switches from factory events and triggers expired ones (see docs/OPERATIONS.md §8c). Once configured, GET / and /llms.txt advertise the factory address. Details: contracts/README.md.

Audit status: unaudited. An internal review fixed one High and one Medium (zero outstanding critical/high), but that is not a third-party audit. A self-contained audit packet is at contracts/audit/SCOPE.md; the audit target is tag audit-v1, which matches the verified bytecode of the live mainnet factory. We are engaging an external auditor and will publish the report here — until then, don't deposit more than you'd be willing to lose.

MCP server and discovery

POST /mcp is a remote MCP server (Streamable HTTP, stateless, JSON responses). Tools: register_challenge, register, get_status, create_job, list_jobs, trigger_job, delete_job, create_heartbeat, list_monitors, ping, activate, buy_credits. Each tool calls the REST API in-process, so auth, rate limits, idempotency (idempotencyKey argument) and billing behave identically.

{
  "mcpServers": {
    "time2live": {
      "type": "http",
      "url": "https://time2live.xyz/mcp",
      "headers": { "Authorization": "Bearer t2l_…" }
    }
  }
}

An agent without a key can connect without the header, call register_challenge, sign the message with its wallet and call register; later tools take the key via the header or an apiKey argument. Paid tools follow the x402 MCP transport: the result carries PaymentRequired (isError, structuredContent), and an x402 MCP client (@x402/mcp createx402MCPClient) pays and retries with _meta["x402/payment"]; the receipt is returned in _meta["x402/payment-response"].

Discovery: GET / (service summary and links), GET /llms.txt, GET /openapi.json and the MCP Server Card at /.well-known/mcp/server-card.json (alias /.well-known/mcp.json; SEP-1649 format, generated from the live tool list).

GET / uses content negotiation: browsers (Accept: text/html) get a lightweight landing page (single self-contained HTML file, no build step), while agents and curl get the JSON summary — so the discovery contract at / is unchanged for machines.

Production deployment

The service runs on a single Hetzner VPS with Docker Compose (api, worker, Postgres, Caddy for automatic HTTPS). Images are built on the server from a git checkout — no registry or deploy secrets. docs/OPERATIONS.md is the authoritative runbook; the essentials:

  • Provision (once): deploy/create-server.ps1 creates the server and firewall; provision.sh hardens it (SSH key only, no root login, ufw, fail2ban, unattended upgrades, Postgres not exposed) and installs a read-only deploy key. Configure secrets in /opt/time2live/.env (ENCRYPTION_KEY, POSTGRES_PASSWORD, and optional TELEGRAM_*, X402_*, KEEPER_*).
  • Deploy: deploy/deploy.ps1 -Ref main (or ssh time2live /opt/time2live/update.sh <sha>) — builds the image, runs migrations before starting, does a deep health check and rolls back on failure, keeping the last 5 images.
  • Backups: nightly encrypted pg_dump to a Hetzner Storage Box via restic (systemd timer); restore is documented and tested in OPERATIONS §6.
  • Monitoring: GET /healthz?deep=1, the worker's own self-heartbeat monitor, and an external uptime check. Enabling x402 (§8b) and the on-chain keeper (§8c) is optional and off by default.

Secrets live only in /opt/time2live/.env on the server, never in the repo.

Reliability (current posture): the service runs on a single node — there is no high-availability layer yet, so the recovery path for a host failure is restore-from-backup (OPERATIONS §6), with an RPO of one night and an RTO bounded by reprovision + restore. This affects off-chain scheduling and heartbeats only: on-chain switch liveness does not depend on our node — trigger() is permissionless and reward-incentivised (above), so a funded switch still fires if our keeper is down. HA and a hosted public testnet are on the roadmap, not done.

Admin

npm run admin -- show <accountId|address>
npm run admin -- freeze <accountId|address> --reason "abuse report"
npm run admin -- unfreeze <accountId|address>
npm run admin -- telegram-webhook   # register <PUBLIC_BASE_URL>/telegram/webhook with Telegram
# production: docker compose exec api node dist/bin/admin.js …

A frozen account gets 403 account_frozen on every API call and cannot obtain new keys; its jobs stop producing runs and pending deliveries are cancelled. Its pings are still accepted, but no alerts are sent.

Operator controls (emergency stop & overview)

Agents operate; the human owner supervises. These are API-key authed, so the owner (or a browser holding a key) can use them:

  • POST /v1/account/pause-all — kill switch: pauses every active job (cancelling pending runs) and every monitor (no more alerts). Nothing is deleted; resume individually when ready.
  • POST /v1/account/keys/revoke-all — revokes every API key, including the caller's, locking the agent out at once. Sign in again with the wallet to mint a new key.
  • GET /v1/account/overview — one read-only call returning account, tier, usage, credit balance, job/monitor status counts, and the most recent monitors, jobs and payments.

Operator dashboard

GET /dashboard is a lightweight browser page (single self-contained HTML file) where the human owner connects their wallet, signs once, and supervises the fleet: account, credits, monitors, jobs and payments, with the two emergency-stop buttons. Sign-in uses POST /v1/auth/session, which verifies a SIWE signature like /v1/auth/verify but returns a short-lived (1 h) operator session token (t2ls_…) instead of a persistent API key — nothing is stored server-side, and it deliberately survives revoke-all, so the human keeps access while the agent's keys are cut. The token authenticates the same endpoints as an API key (Authorization: Bearer t2ls_…); agents keep using API keys.

GET /healthz checks the process only; GET /healthz?deep=1 also checks Postgres and that a worker ticked within HEALTH_MAX_TICK_AGE_MS (503 otherwise) — point external uptime checks there.

License

Copyright © 2026 time2live.xyz. Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-only). You may use, modify and self-host it; if you run a modified version as a network service, you must make your source available to its users (AGPL §13). For a commercial license without the AGPL obligations, contact the maintainer.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages