Skip to content

Commit b01d4e6

Browse files
plcclaude
andcommitted
Human accounts, event webhooks, claim recommendations, and test coverage
- Human accounts: signup/login/dashboard with session auth, X-Human-Key header for API claiming, POST /agents/claim, auto-claim on POST /agents - Event webhooks: fire-and-forget POST to calendar webhook_url on event CRUD, HMAC-SHA256 signing via webhook_secret, POST /calendars/:id/webhook/test - Claim recommendations: GET /man and GET /changelog suggest unclaimed agents with events should be claimed by a human account - Docs: split "Feeds & Webhooks" into "Feeds & Inbound Email" + "Event Webhooks", clarified inbound email vs event webhook terminology throughout - Tests: 19 new tests covering human auth flow, POST /agents/claim, calendar view (GET /view), webhook test endpoint; helpers now support custom headers - Docker: NODE_ENV passthrough in docker-compose for test rate limit bypass Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dad13f7 commit b01d4e6

File tree

21 files changed

+1323
-79
lines changed

21 files changed

+1323
-79
lines changed

CALDAVE_SPEC.md

Lines changed: 85 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,19 @@ Agents need to know when it's time to act. Two mechanisms:
6969

7070
**Polling (primary):** Agents call `GET /calendars/:id/upcoming` to check what's next. Simple, stateless, works with any agent framework.
7171

72-
**Webhooks (optional, per-calendar):** A calendar can register a webhook URL. CalDave sends a POST to that URL at configurable offsets before an event starts (e.g. 5 minutes before). Webhook delivery uses a simple retry policy (3 attempts, exponential backoff).
72+
**Webhooks (optional, per-calendar):** A calendar can register a webhook URL. CalDave sends a POST to that URL whenever events are created, updated, deleted, or responded to — whether via the API or inbound email. Payloads are signed with HMAC-SHA256 if a `webhook_secret` is set. Delivery is fire-and-forget.
7373

7474
---
7575

7676
## Auth Model
7777

7878
| Concept | Details |
7979
|---------|---------|
80-
| **API Key** | Bearer token, one per agent. Sent as `Authorization: Bearer <key>` |
80+
| **Agent API Key** | Bearer token, one per agent. Sent as `Authorization: Bearer sk_live_...` |
81+
| **Human API Key** | One per human account. Passed as `X-Human-Key` header for claiming agents. |
8182
| **Provisioning** | `POST /agents` returns `agent_id` + `api_key`. Agent must store these — the key is shown once. |
83+
| **Human Accounts** | Humans sign up at `/signup` to get a dashboard and human API key (`hk_live_...`). |
84+
| **Agent Claiming** | Humans claim agents by providing the agent's `sk_live_` key — via dashboard or `POST /agents/claim` with `human_key`. |
8285
| **Scoping** | An API key grants access to all calendars owned by that agent. No cross-agent access. |
8386

8487
---
@@ -88,7 +91,12 @@ Agents need to know when it's time to act. Two mechanisms:
8891
### Agent Provisioning
8992

9093
#### `POST /agents`
91-
Creates a new agent identity. No auth required. Optionally accepts `name` and `description` to identify the agent.
94+
Creates a new agent identity. No auth required. Optionally accepts `name` and `description` to identify the agent. Pass the `X-Human-Key` header to associate with a human account.
95+
96+
**Headers (optional):**
97+
| Header | Description |
98+
|--------|-------------|
99+
| `X-Human-Key` | Human API key (`hk_live_...`) to auto-associate the new agent |
92100

93101
**Request (optional):**
94102
```json
@@ -105,7 +113,35 @@ Creates a new agent identity. No auth required. Optionally accepts `name` and `d
105113
"api_key": "sk_live_abc123...",
106114
"name": "My Assistant",
107115
"description": "Manages team calendars and sends meeting reminders",
108-
"message": "Store these credentials securely. The API key will not be shown again."
116+
"message": "Store these credentials securely. The API key will not be shown again.",
117+
"owned_by": "hum_abc123..."
118+
}
119+
```
120+
121+
The `owned_by` field only appears when `X-Human-Key` header is provided.
122+
123+
#### `POST /agents/claim`
124+
Claim an existing agent by providing its API key. Requires `X-Human-Key` header.
125+
126+
**Headers:**
127+
| Header | Description |
128+
|--------|-------------|
129+
| `X-Human-Key` | **Required.** Human API key (`hk_live_...`) |
130+
131+
**Request:**
132+
```json
133+
{
134+
"api_key": "sk_live_abc123..."
135+
}
136+
```
137+
138+
**Response:**
139+
```json
140+
{
141+
"agent_id": "agt_x7y8z9",
142+
"agent_name": "My Assistant",
143+
"claimed": true,
144+
"owned_by": "hum_abc123..."
109145
}
110146
```
111147

@@ -444,26 +480,29 @@ Set or update webhook configuration:
444480
}
445481
```
446482

447-
**Webhook payload (POST to the registered URL):**
483+
#### Webhook Event Types
484+
485+
When a calendar has a `webhook_url` configured, CalDave automatically delivers a webhook POST whenever events change — via the API or inbound email.
486+
487+
| Type | Trigger |
488+
|------|---------|
489+
| `event.created` | New event via POST or inbound email |
490+
| `event.updated` | Event modified via PATCH or inbound email update |
491+
| `event.deleted` | Event deleted via DELETE or inbound email CANCEL |
492+
| `event.responded` | Event accepted/declined/tentative via POST respond |
493+
494+
**Payload:**
448495
```json
449496
{
450-
"type": "event.upcoming",
451-
"calendar_id": "cal_a1b2c3",
452-
"event": { ... },
453-
"fires_at": "2025-02-15T08:55:00-07:00",
454-
"offset": "-5m",
455-
"signature": "sha256=..."
497+
"type": "event.created",
498+
"calendar_id": "cal_...",
499+
"event_id": "evt_...",
500+
"event": { "id": "evt_...", "title": "Meeting", ... },
501+
"timestamp": "2026-02-17T12:00:00.000Z"
456502
}
457503
```
458504

459-
Signature is HMAC-SHA256 of the body using `webhook_secret`.
460-
461-
#### `GET /calendars/:id/webhook-logs`
462-
List webhook delivery attempts for a calendar. Supports query params:
463-
- `status` — filter by `pending` / `delivered` / `failed`
464-
- `limit` / `offset` — pagination
465-
466-
Useful for debugging failed deliveries.
505+
If `webhook_secret` is set, the payload is signed with HMAC-SHA256 and sent in the `X-CalDave-Signature` header. Delivery is fire-and-forget (no retries). Use `POST /calendars/:id/webhook/test` to verify your endpoint.
467506

468507
---
469508

@@ -477,9 +516,9 @@ Read-only iCalendar feed. Can be subscribed to from Google Calendar, Apple Calen
477516
### Inbound Email
478517

479518
#### `POST /inbound/:token`
480-
Per-calendar webhook endpoint for receiving forwarded emails containing `.ics` calendar invite attachments.
519+
Per-calendar endpoint for receiving forwarded emails containing `.ics` calendar invite attachments. Creates, updates, or cancels events based on the iCal METHOD.
481520

482-
No Bearer auth. The unguessable `inbound_token` in the URL authenticates the request. Each calendar gets its own unique webhook URL (returned at creation as `inbound_webhook_url`).
521+
No Bearer auth. The unguessable `inbound_token` in the URL authenticates the request. Each calendar gets its own unique inbound URL (returned at creation as `inbound_webhook_url`).
483522

484523
Supports multiple email-to-webhook providers:
485524
- **Postmark Inbound** — attachments include base64 content inline in the webhook payload
@@ -691,18 +730,32 @@ For local development or offline use, run the STDIO server directly:
691730
- `calendars(email)` — for inbound email lookup
692731
- `calendars(inbound_token)` — partial index for webhook URL lookup
693732

694-
### `webhook_deliveries`
733+
### `humans`
695734
| Column | Type | Notes |
696735
|--------|------|-------|
697-
| `id` | `text` PK | `whd_` prefixed |
698-
| `calendar_id` | `text` FK | |
699-
| `event_id` | `text` FK | |
700-
| `offset` | `text` | e.g. `-5m` |
701-
| `status` | `text` | `pending` / `delivered` / `failed` |
702-
| `attempts` | `integer` | Number of delivery attempts |
703-
| `last_attempt_at` | `timestamptz` | |
704-
| `response_status` | `integer` | HTTP status from webhook target |
705-
| `error` | `text` | Nullable, error message on failure |
736+
| `id` | `text` PK | `hum_` prefixed |
737+
| `name` | `text` | |
738+
| `email` | `text` UNIQUE | Case-insensitive index on `LOWER(email)` |
739+
| `password_hash` | `text` | bcryptjs, 12 salt rounds |
740+
| `api_key_hash` | `text` | SHA-256 hash of the human API key (`hk_live_...`) |
741+
| `created_at` | `timestamptz` | |
742+
743+
### `human_agents`
744+
| Column | Type | Notes |
745+
|--------|------|-------|
746+
| `id` | `text` PK | `ha_` prefixed |
747+
| `human_id` | `text` FK | → humans(id) CASCADE |
748+
| `agent_id` | `text` FK | → agents(id) CASCADE |
749+
| `claimed_at` | `timestamptz` | |
750+
751+
UNIQUE(human_id, agent_id)
752+
753+
### `human_sessions`
754+
| Column | Type | Notes |
755+
|--------|------|-------|
756+
| `id` | `text` PK | `sess_` prefixed |
757+
| `human_id` | `text` FK | → humans(id) CASCADE |
758+
| `expires_at` | `timestamptz` | 7-day expiry, cleaned up hourly |
706759
| `created_at` | `timestamptz` | |
707760

708761
---
@@ -748,7 +801,7 @@ Rate limit headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-R
748801

749802
1. **Agent provisioning auth** — Open, no operator key. Rate-limited (see Rate Limits section).
750803
2. **Feed authentication** — Token required. Feed URLs include a token param: `/feeds/:calendar_id.ics?token=feed_abc123`. Token is generated at calendar creation and returned in the response.
751-
3. **Webhook logs**Failed deliveries are queryable via `GET /calendars/:id/webhook-logs`.
804+
3. **Webhook delivery**Fire-and-forget on event mutations. Use `POST /calendars/:id/webhook/test` to verify endpoint reachability.
752805
4. **Domain**`caldave.ai` for v1. Email addresses: `cal-abc123@caldave.ai`.
753806
5. **Event size limits** — 64KB for description, 16KB for metadata JSON.
754807
6. **Outbound email** — Implemented. Creating/updating events with attendees sends METHOD:REQUEST invite emails via Postmark. Responding to inbound invites sends METHOD:REPLY emails to the organiser. Requires `POSTMARK_SERVER_TOKEN` env var; gracefully skipped if not set.
@@ -758,7 +811,6 @@ Rate limit headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-R
758811

759812
## Out of Scope (v1)
760813

761-
- Web UI / dashboard
762814
- Calendar sharing between agents
763815
- Attachments on events
764816
- Free/busy lookups

CHANGELOG.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
77
## [Unreleased]
88

99
### Added
10+
- **Webhook delivery on event mutations** — Calendars with a `webhook_url` now receive automatic webhooks when events are created, updated, deleted, or responded to. Works for both API operations and inbound email invites. Event types: `event.created`, `event.updated`, `event.deleted`, `event.responded`. Payloads are signed with HMAC-SHA256 if `webhook_secret` is set. Delivery is fire-and-forget (no retries).
11+
- **Human accounts with agent key claiming** — Humans can sign up at `/signup`, log in at `/login`, and manage their agent keys from a `/dashboard`. Agents are claimed by providing the agent's secret key (`sk_live_...`) — if you have the key, you own it. No calendar invites needed.
12+
- **Human API keys** (`hk_live_...`) — pass via `X-Human-Key` header to `POST /agents` to auto-associate new agents, or to `POST /agents/claim` to claim existing agents programmatically.
13+
- **Dashboard** — web UI to view claimed agents, claim new ones by pasting a secret key, and release agents.
14+
- **Session auth** — cookie-based sessions for the dashboard (7-day expiry, automatic cleanup).
15+
- New tables: `humans`, `human_agents`, `human_sessions`.
16+
1017
- **MCP server at full parity with API** — Added 16 new MCP tools covering all documented API endpoints. Previously the MCP server only had 8 core tools; it now exposes 24 tools matching every REST endpoint:
1118
- **Agent management**: `caldave_get_agent`, `caldave_update_agent`
1219
- **SMTP configuration**: `caldave_set_smtp`, `caldave_get_smtp`, `caldave_delete_smtp`, `caldave_test_smtp`
@@ -48,7 +55,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
4855
- **Personalized recommendations in changelog**`GET /changelog` with auth now includes a `recommendations` array with actionable suggestions based on agent state (e.g. name your agent, create your first calendar, add a description).
4956
- **API changelog endpoint**`GET /changelog` returns a structured list of API changes with dates and docs links. With optional Bearer auth, highlights changes introduced since the agent was created. Designed for agents to poll ~weekly.
5057
- **Agent metadata**`POST /agents` now accepts optional `name` and `description` fields to identify agents. New `GET /agents/me` returns the agent's profile. New `PATCH /agents` updates metadata without changing the API key. Agent name and description are surfaced in `GET /man` context.
51-
- **Outbound calendar invites** — when an event is created or updated with attendees, CalDave sends METHOD:REQUEST iCal invite emails via Postmark. Invites include `.ics` attachments that work with Google Calendar, Outlook, and Apple Calendar. From address is the calendar's email so replies route back through the inbound webhook.
58+
- **Outbound calendar invites** — when an event is created or updated with attendees, CalDave sends METHOD:REQUEST iCal invite emails via Postmark. Invites include `.ics` attachments that work with Google Calendar, Outlook, and Apple Calendar. From address is the calendar's email so replies route back through inbound email.
5259
- **Agent name in outbound emails** — when an agent has a name set (via `PATCH /agents`), outbound invite and RSVP reply emails use `"Agent Name" <calendar-email>` as the From address, so recipients see a friendly display name instead of just the calendar email.
5360
- **Outbound RSVP replies** — when an agent responds to an inbound invite via `POST /respond`, CalDave sends a METHOD:REPLY iCal email back to the organiser with the agent's acceptance, decline, or tentative status.
5461
- **Graceful degradation** — if `POSTMARK_SERVER_TOKEN` is not set, outbound emails are silently skipped. All API endpoints continue to work normally.
@@ -111,8 +118,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
111118
- Uses `rrule` npm package for RRULE parsing and expansion
112119
- **API documentation page** (`GET /docs`) — self-contained HTML docs with curl examples for every endpoint, copy buttons, quick start guide, and dark theme matching the status page
113120
- Full API spec in `CALDAVE_SPEC.md`
114-
- **Inbound email support** — receive calendar invites via per-calendar webhook URLs
115-
- `POST /inbound/:token` — unique webhook URL per calendar (token in URL authenticates)
121+
- **Inbound email support** — receive calendar invites via per-calendar inbound URLs
122+
- `POST /inbound/:token` — unique inbound URL per calendar (token in URL authenticates)
116123
- Each calendar gets an `inbound_webhook_url` returned at creation and in GET responses
117124
- Parses `.ics` attachments from inbound emails using `node-ical`
118125
- Creates events with `source: inbound_email`, `status: tentative`
@@ -134,7 +141,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
134141
- **Rate limiting** — replaced stub headers with real enforcement via `express-rate-limit` (200/min API, 5/hour agent creation, 60/min inbound webhooks)
135142
- **Security headers** — added `helmet` middleware (X-Content-Type-Options, X-Frame-Options, HSTS, CSP, etc.)
136143
- **Request body size limit** — explicit 512KB limit on `express.json()` to prevent oversized payloads
137-
- **Inbound webhook token hardening** — invalid tokens now return 200 (not 404) to prevent token validity enumeration
144+
- **Inbound token hardening** — invalid tokens now return 200 (not 404) to prevent token validity enumeration
138145
- **RRULE frequency restriction** — reject `FREQ=SECONDLY` and `FREQ=MINUTELY` (expansion blocks event loop for 18s+)
139146
- **init-db.sh SQL quoting**`CREATE DATABASE` now uses quoted identifier to prevent SQL injection via `DB_NAME`
140147
- **Input validation** — length limits on calendar name (255), timezone (64), event title (500), location (500); webhook URL format validation
@@ -151,7 +158,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
151158
- Schema uses `CREATE TABLE IF NOT EXISTS` — no migration tool needed for v1
152159
- nanoid (v5) used for all ID generation with alphanumeric alphabet
153160
- API keys use SHA-256 (not bcrypt) for deterministic lookup by hash
154-
- Webhook columns exist in schema but webhook delivery is deferred
161+
- Webhook delivery fires on all event mutations (event.created, event.updated, event.deleted, event.responded)
155162
- Port 3720 generated from `get-port.sh caldave`
156163

157164
---

SPEC.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,16 @@ fly deploy
2727
| `GET /` | No | Status page |
2828
| `GET /health` | No | Server health check |
2929
| `GET /health/db` | No | Database health check |
30-
| `POST /agents` | No | Create agent (returns API key) |
30+
| `POST /agents` | No (optional `X-Human-Key`) | Create agent (returns API key). With human key, auto-claims. |
31+
| `POST /agents/claim` | `X-Human-Key` | Claim an existing agent by providing its `sk_live_` key |
32+
| `GET /signup` | No | Human account signup page |
33+
| `POST /signup` | No | Create human account |
34+
| `GET /login` | No | Human account login page |
35+
| `POST /login` | No | Authenticate human |
36+
| `POST /logout` | Cookie | Log out (clear session) |
37+
| `GET /dashboard` | Cookie | Dashboard — manage claimed agents |
38+
| `POST /dashboard/claim` | Cookie | Claim agent via dashboard |
39+
| `POST /dashboard/agents/:id/release` | Cookie | Release agent ownership |
3140
| `GET /man` | Optional | Machine-readable API manual (JSON), personalized if authenticated |
3241
| `POST /calendars` | Yes | Create calendar |
3342
| `GET /calendars` | Yes | List agent's calendars |
@@ -61,10 +70,14 @@ src/
6170
│ ├── outbound.js — Outbound email (iCal invite/reply generation, Postmark sending)
6271
│ └── recurrence.js — RRULE parsing, materialization, horizon management
6372
├── middleware/
64-
│ ├── auth.js — Bearer token auth
65-
│ └── rateLimitStub.js — Stub rate limit headers
73+
│ ├── auth.js — Bearer token auth (agents)
74+
│ ├── humanAuth.js — Session cookie + X-Human-Key auth (humans)
75+
│ └── rateLimit.js — Rate limiting (express-rate-limit)
76+
├── lib/
77+
│ ├── passwords.js — bcryptjs password hashing
6678
└── routes/
67-
├── agents.js — POST /agents
79+
├── agents.js — POST /agents, POST /agents/claim
80+
├── humans.js — Signup, login, dashboard, claim, release
6881
├── man.js — GET /man (machine-readable API manual)
6982
├── calendars.js — Calendar CRUD
7083
├── events.js — Event CRUD + upcoming + respond
@@ -78,7 +91,7 @@ scripts/
7891

7992
## Database Schema
8093

81-
Five tables: `agents`, `calendars`, `events`, `postmark_webhooks`, `error_log`. Schema auto-created on startup.
94+
Eight tables: `agents`, `calendars`, `events`, `postmark_webhooks`, `error_log`, `humans`, `human_agents`, `human_sessions`. Schema auto-created on startup.
8295

8396
See `CALDAVE_SPEC.md` for full column definitions.
8497

@@ -117,14 +130,14 @@ Events can be created with `all_day: true`. When set:
117130

118131
## Auth Model
119132

120-
- API keys: `sk_live_` prefix + 32 alphanumeric chars
121-
- Stored as SHA-256 hash (direct DB lookup, no iteration)
133+
- **Agent API keys**: `sk_live_` prefix + 32 alphanumeric chars, SHA-256 hashed
134+
- **Human API keys**: `hk_live_` prefix + 32 alphanumeric chars, SHA-256 hashed
135+
- **Human sessions**: `sess_` prefix + 32 chars, cookie-based (7-day expiry)
136+
- **Human passwords**: bcryptjs with 12 salt rounds
122137
- Agent scoping: each agent only sees their own calendars/events
138+
- Human-agent ownership: humans claim agents by providing the agent's secret key
123139

124140
## Deferred
125141

126142
- DST-aware recurrence (times currently repeat at same UTC offset, may drift ±1h across DST)
127143
- iCal feed with RRULE + EXDATE (currently emits individual VEVENTs)
128-
- Webhooks / push notifications (pg-boss)
129-
- MCP tools
130-
- Real rate limiting (headers are stubbed)

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ services:
3232
ports:
3333
- "${PORT:-3000}:${PORT:-3000}"
3434
environment:
35+
- NODE_ENV=${NODE_ENV:-development}
3536
- DATABASE_URL=postgres://plc:postgres@host.docker.internal:5432/${DB_NAME:-caldave}
3637
- PORT=${PORT:-3000}
3738
- INBOUND_WEBHOOK_SECRET=${INBOUND_WEBHOOK_SECRET:-}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"license": "MIT",
2121
"dependencies": {
2222
"@modelcontextprotocol/sdk": "^1.26.0",
23+
"bcryptjs": "^3.0.3",
24+
"cookie-parser": "^1.4.7",
2325
"express": "^4.18.2",
2426
"express-rate-limit": "^8.2.1",
2527
"helmet": "^8.1.0",

0 commit comments

Comments
 (0)