API key expiry sweep and reminders
Part of epic 9326 (Jaah API keys). Child C3 of the Architect's decomposition: hourly sweep, expiry reminders, expiry audit, disabled-owner backstop and the in-app inbox kind. Depends on the backend child (the `api_keys` columns, `api_keys.py` rules module) and the choke-point child (`user_notify.deliver`). Adds NO migration: the reminder/expiry columns already exist.
## Original requirement (verbatim)
> Implement Jaah API keys workflow.
> - Permissions: same as we have internally: subject:action. Access to object is limited to the owner of the key. Should be very easy to configure on the UI. By default all is max, but user can remove some
> - API Keys can only be created by at least Org Admin
> - Keys have expiration
> Do a web research and suggest the most modern approach to this for this type of systems
## Requirements this child delivers (PRD, verbatim)
- **R13 Expiry reminders** to the owner 7 days and 1 day before expiry (none for "No expiration" keys). **In-app and email mandatory, plus every other channel the user has configured.** Sent through the **single standard notification choke point** with an **"important" flag that forces the email channel**; the notifier does not know or choose the user's channels.
- **R12** (expire part): audit event for expire.
- **R3** (backstop): Removed from org / user deleted / disabled → all their keys permanently revoked.
## Approach (Architecture §8)
- Register `api-keys-hourly` in `apps/api/app/cron_system_activities.py` with `_system(API_KEYS_HOURLY, …, interval=3600)` (pattern `:881-915`) → `api_keys.sweep(now)`. Hourly, not daily, so a key created with < 1 day left still gets its reminder within the hour.
- `sweep(now)`:
1. Each live key with `expires_at − now ≤ 7d` and no `reminded_7d_at`, or `≤ 1d` and no `reminded_1d_at` (a 1-day send also stamps 7d), calls `user_notify.deliver(session, owner, event, event_name="api_key_expiry", important=True)` — no channel named. The event carries the key name, display id, expiry and the console link `/settings/api-keys/<id>`.
2. Each key past `expires_at` with no `expiry_recorded_at` gets an `api_key_expired` audit row and the stamp.
3. Each unrevoked key whose owner is disabled is revoked with `owner_disabled` (+ audit), via the backend's `revoke_for_owner`.
Keys with `expires_at` NULL never trigger a reminder. Idempotent across runs.
- **Inbox kind `api_key_expiring`** in `apps/api/app/routes/inbox.py` (derived, no notifications table, G6): from `api_keys` where `owner_id = caller`, a reminder stamp later than `reminder_seen_at`, and not revoked. `seen` stamps `reminder_seen_at`.
- **Web**: `MentionsBell.tsx` renders the server's sentence and links to `/settings/api-keys/<id>`. The sentence must read like a person wrote it, e.g. "API key ‘ci-deploy’ expires in 1 day (Oct 30)" — name, relative + absolute expiry — never ids or raw timestamps.
Tests: API tests for 7d/1d windows, double-run idempotence, no reminder for never-expiring keys, `important=True` passed and no channel named, expiry audit row once, disabled-owner backstop, inbox derivation + `seen`; a web unit test for the MentionsBell `api_key_expiring` row (text + link).
Credentials & reach:
- AC9 on dev | `me/jaah/debug/main/operator-login` on `https://app.dev.jaah.ai` (control plane `dev.jaah.ai`) | **verified**: `credential_get` succeeded; login probe answered 200 (role owner).
- AC14 email | The `notification_sent` row on dev `/audit` + the choke-point child's API test | **verified** for the delivery record. **Inbox arrival deferred**: no mailbox credential on the roster (D-A5).
- AC14 other channels (e.g. Slack) | The same `notification_sent` row (per-channel outcome) + API test | **deferred** for arrival (D-A5): no credential to read a person's Slack/Telegram DMs.
Out of scope: Do NOT add a migration or a notifications table. Do NOT change `user_notify` channel logic (choke-point child) or the auth path (backend child). Do NOT migrate existing notifiers (`alert_record.py`, `governance/notify.py`, `comment_notify.py`, `task_notify.py`) — deferred. No org-level max-lifetime policy. No per-key rate limits.
Done means:
- AC14 Expiry reminders — Key expiring in ≤7 days (and ≤1 day) → owner receives in-app notification AND email, plus any other channel they configured (e.g. Slack if linked). Notifier code calls the one choke point with `important=True` and names no channel (code review check). Keys with "No expiration" → no reminder. Oracle on dev (D-A5): the in-app bell shows a readable reminder (key name + expiry) linking to the key; `/audit` shows the `notification_sent` row with `important` and email `sent` (or `skipped: no_email_config` if dev has no sender); API tests prove the rest.
- AC9 (sweep half) — after a key expires, `/audit` shows one `api_key_expired` row for it.
- Disabled owner backstop: a key whose owner is disabled outside the users route is revoked (`owner_disabled`) by the next sweep (test).
- New API tests and the MentionsBell web test green; `./dc check quick` green.
Outcome
Built the hourly api-keys-hourly sweep: 7d/1d reminders through user_notify (important, no channel), one api_key_expired row per key, disabled-owner revoke, bell kind api_key_expiring. Quick gate green; review lanes pass; #6739 merged, deploy green. Visual check done: the bell row reads cleanly on desktop and phone. Assumed a data-only cron seed revision. Memory: used mem-24480, mem-15203; proposed 0.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)