End-to-end smoke runbook¶
Step-by-step verification of the full link → check-in → revoke
chain once the relevant PRs land. Pairs with docs/ROADMAP.md §3,
which has the higher-level checklist; this file has the actual click
paths, curl commands, and SQL verifications.
You can run this against:
- Local dev — last1.id + RadioCheck on
supabase start, LastVet on Railway preview / local Postgres - Production — full prod stack
Use a throwaway test account in both modes. The revoke flow permanently destroys session state; you don't want to do this to a real veteran or provider.
The verification half of every revoke check uses the helper script
scripts/smoke-verify-revoke.mjs rather than hand-refreshing SQL.
Prerequisites¶
These must be true before any of the chains below will work. Tick each as you confirm.
Deploys¶
Reference: docs/ROADMAP.md §1.
- [ ]
last1.idis onmainwith at least PRs #46 (admin force-unlink), #52 (Connected Apps enrich) deployed - [ ]
last1-adminis onmainwith PR #2 deployed (if testing admin force-unlink, §3 below) - [ ]
last.vet-apihas #12 → #13 → #14 all deployed; migration023ran;LAST1_TOKEN_ENCRYPTION_KEYset on Railway - [ ]
last.vet-providerhas PR #4 deployed - [ ]
last.vet-ioshas PRs #2 and #3 in TestFlight - [ ]
RadioCheckhas PRs #6 (connected screen) and #7 (revoke webhook) deployed
Webhook configuration¶
For each partner you're testing the revoke chain on, both sides must be configured:
- [ ] last1.id
app_clientsrow exists for the partner with: webhook_urlpointing at the partner's inbound endpointwebhook_secret_b64provisioned (32 random bytes, base64)webhook_enabled = true- [ ] Partner has the same
webhook_secret_b64set in its environment: - LastVet:
LAST1_WEBHOOK_SECRETon Railway - RadioCheck:
supabase secrets set LAST1_INBOUND_WEBHOOK_SECRET=<b64>
Throwaway test account¶
You need one veteran identity that exists on last1.id. Easiest
recipe:
# Sign in via the regular flow at https://auth.last1.id (or http://localhost:5003
# in dev) using an @last1-test.local email. The first sign-in auto-
# creates the identity row.
Take note of the resulting identity.id UUID. You'll paste it
into the verify script in §3.
To find your own identity id after sign-in:
-- against last1.id Supabase
select id from identities where supabase_user_id = (
select id from auth.users where email = 'YOUR-TEST-EMAIL@last1-test.local'
);
Environment variables for the verify script¶
Set once in your shell session:
# last1.id (always required)
export LAST1_SUPABASE_URL="https://<last1id-project>.supabase.co"
export LAST1_SUPABASE_SERVICE_ROLE_KEY="..." # from Supabase dashboard
# RadioCheck (required when testing the RadioCheck chain)
export RADIOCHECK_SUPABASE_URL="https://<radiocheck-project>.supabase.co"
export RADIOCHECK_SUPABASE_SERVICE_ROLE_KEY="..."
The service-role key bypasses RLS. Never commit it. Set it in your shell, not in
.envchecked into git.
1. Chain: Link veteran → check-in → revoke (RadioCheck)¶
This is the canonical end-to-end. It exercises every PR from §1 of the roadmap that involves RadioCheck.
1.1 Link¶
- [ ] In the RadioCheck iOS app (TestFlight build with PR #6), tap "Sign in with Last1 ID"
- [ ] Complete the OAuth roundtrip in the SFAuthenticationSession sheet:
- Redirect to last1.id
- Sign in with your test account
- Authorize the requested scopes
- Redirect back to
radiocheck:// - [ ] Verify the post-link "Connected to Last1" screen appears with your masked identity id
Verification SQL (against last1.id Supabase):
select identity_id, app_id, linked_at, revoked_at
from identity_app_links
where identity_id = '<YOUR-IDENTITY-ID>'
and app_id = 'radiocheck';
-- expected: 1 row, revoked_at IS NULL
select g.scopes, g.granted_at, g.revoked_at
from consent_grants g
join app_clients a on a.id = g.app_client_id
where g.identity_id = '<YOUR-IDENTITY-ID>'
and a.app_id = 'radiocheck';
-- expected: 1 row with the scopes the iOS authorize page showed
select count(*) as live_sessions
from oauth_refresh_tokens t
join app_clients a on a.id = t.app_client_id
where t.identity_id = '<YOUR-IDENTITY-ID>'
and a.app_id = 'radiocheck'
and t.revoked_at is null
and t.expires_at > now();
-- expected: 1 (or however many devices you've used)
1.2 Check-in¶
- [ ] Trigger a check-in on RadioCheck (any standard radio check-in flow — voice / SMS / app)
- [ ] Wait up to 5 minutes for the proof outbox drainer to flush
Verification SQL (against last1.id Supabase):
select id, source, event_type, created_at
from trust_events
where identity_id = '<YOUR-IDENTITY-ID>'
and app_id = 'radiocheck'
order by created_at desc
limit 3;
-- expected: at least 1 row dated within the last 5 minutes, event_type
-- something like 'radiocheck.checkin.completed'
If the row doesn't appear after 5 minutes, check the RadioCheck
proof_outbox table for stuck rows:
-- against RadioCheck Supabase
select id, status, attempts, last_error, payload->>'event_type' as event_type, created_at
from proof_outbox
where status != 'delivered'
order by created_at desc
limit 10;
1.3 Revoke (from end-user dashboard)¶
- [ ] On
last1.id, visit/dashboard/apps - [ ] Verify the RadioCheck row shows:
- The permissions you granted (with descriptions, not raw scope ids — PR #52)
- "1 active session" badge
- "Last used N ago" with a real timestamp (not "—")
- [ ] Click Revoke access
- [ ] In the confirm modal, verify the copy spells out the count of permissions and active sessions that will be cut
- [ ] Click Revoke access to confirm
Verification — run the helper script:
The script polls every 2s for up to 60s and prints a live table:
✅ last1.identity_app_links.revoked_at IS NOT NULL
revoked_at=2026-05-22T23:50:11.123Z
✅ last1.consent_grants.revoked_at IS NOT NULL
revoked_at=2026-05-22T23:50:11.124Z
✅ last1.oauth_refresh_tokens — all revoked OR expired
0 live refresh tokens remain
✅ last1.webhook_deliveries — app_link.revoked queued
id=... status=delivered created=2026-05-22T23:50:11.224Z
✅ radiocheck.users.last1_identity_id IS NULL
no RadioCheck user bound to this identity
✅ radiocheck.last1_webhook_deliveries — identity_unlinked received
delivery_id=... received=2026-05-22T23:50:11.405Z
6/6 passed
If any check stays ⏳ after 60s, common causes (also printed by the
script):
- Webhook drainer not running on last1.id side (
pm2 status/ Railway logs) - Partner endpoint returning 5xx (check
webhook_deliveries.last_error) webhook_enabled = falseon theapp_clientsrow- Secret mismatch between
last1.idand the partner
1.4 Re-link is possible¶
After a successful revoke, the user should be able to re-link without manual cleanup.
- [ ] In the RadioCheck app, tap "Sign in with Last1 ID" again
- [ ] Complete OAuth as before
- [ ] Verify it succeeds (does NOT 409 with
identity_already_linkedorveteran_already_linked) - [ ] Verify a NEW
identity_app_linksrow exists (or the existing row'srevoked_atwas nulled — implementation detail, both are valid)
2. Chain: Disconnect from LastVet provider portal¶
This exercises last.vet-provider #4 + last.vet-api #14 + last1.id #52.
2.1 Link¶
- [ ] Sign in to the LastVet provider portal
- [ ] Visit
/dashboard/profile→ Linked Accounts card - [ ] Click Link Last1 ID → OAuth roundtrip
- [ ] Verify the card flips to "Linked" with a masked identity id
Verification SQL (against LastVet Postgres on Railway):
select id, last1_identity_id, last1_tokens_updated_at
from providers
where id = '<YOUR-PROVIDER-ID>';
-- expected: last1_identity_id is the same UUID you saw at sign-in,
-- last1_tokens_updated_at within the last 60s
2.2 Disconnect¶
- [ ] On the provider portal, click Disconnect in the Linked Accounts card
- [ ] In the confirm modal, verify the copy mentions upstream revoke + local clear + re-link required
- [ ] Click Disconnect
Verification SQL (LastVet Postgres):
select last1_identity_id,
last1_access_token_ciphertext is null as access_cleared,
last1_refresh_token_ciphertext is null as refresh_cleared,
last1_tokens_updated_at
from providers
where id = '<YOUR-PROVIDER-ID>';
-- expected: last1_identity_id = NULL,
-- access_cleared = true,
-- refresh_cleared = true
Verification SQL (LastVet Postgres, audit trail):
select event_type, metadata->>'revoke_result' as revoke_result, created_at
from provider_auth_audit_events
where provider_id = '<YOUR-PROVIDER-ID>'
and event_type = 'unlink_last1_id'
order by created_at desc
limit 3;
-- expected: most recent row, revoke_result either 'revoked' or
-- a categorical reason (network_error, no_stored_refresh_token, etc.)
Verification — the upstream revoke at last1.id (best-effort):
Note: when the revoke_result above is no_stored_refresh_token or
network_error, the LastVet-side clear still happens (best-effort
contract), but the last1.id-side oauth_refresh_tokens may not all
be revoked. That's expected — the next user re-link replaces them
anyway.
3. Chain: Admin force-unlink¶
This exercises last1.id #46 + last1-admin #2.
3.1 Link¶
Pre-state: the test identity must be linked to at least one partner. Use §1.1 (RadioCheck) or §2.1 (LastVet) to establish a link.
3.2 Force unlink¶
- [ ] Sign in to
last1-adminwith operator credentials - [ ] Navigate to Identities → search for the test identity
- [ ] Click the Apps button on the row
- [ ] In the Connected Apps dialog, click Force unlink on the partner row
- [ ] In the confirm dialog, type a reason (min 5 chars, e.g. "smoke test")
- [ ] Click Force unlink to confirm
Verification — same script as §1.3:
node scripts/smoke-verify-revoke.mjs \
--identity-id '<TEST-IDENTITY-ID>' \
--app-id <radiocheck|lastvet>
Plus an admin-audit-trail check:
-- against last1.id Supabase
select action, metadata->>'reason' as reason, metadata->>'source' as source, created_at
from admin_audit_logs
where action = 'identity.app_link.force_revoke'
and metadata->>'identity_id' = '<TEST-IDENTITY-ID>'
order by created_at desc
limit 1;
-- expected: 1 row, source='admin_initiated', reason="smoke test"
3.3 Half-state recovery (optional but recommended)¶
A subtle scenario: the link row was already revoked but a stray
refresh token still has access. The force-unlink endpoint should
still kill that token even though identity_app_links.revoked_at
was already set.
To test:
- [ ] Manually update
identity_app_links.revoked_at = now()for a test pair, but leave anoauth_refresh_tokensrow live - [ ] Trigger force unlink via the admin UI
- [ ] Verify
oauth_refresh_tokens.revoked_atis now set - [ ] Verify a new
admin_audit_logsrow exists (the action is not idempotent at the audit-trail level — every click records the intent) - [ ] Verify a partner webhook was queued even though the link state didn't visibly change (the half-state IS a state change worth notifying about)
4. Smoke test for the "Already revoked" idempotency path¶
The end-user /dashboard/apps revoke path is idempotent. Hitting it
twice should not fail.
- [ ] Link partner via §1.1 or §2.1
- [ ] Revoke via §1.3 — confirm green
- [ ] Browser dev-tools → re-fire the same
POST /api/identity/app-links/:app_id/revokerequest - [ ] Expected response:
{ unlinked: false, alreadyUnlinked: true }with status 200, no new webhook queued - [ ] Confirm by re-running
smoke-verify-revoke.mjsand checking thatwebhook_deliveriesstill has exactly 1app_link.revokedrow for this identity, not 2
5. Common debug commands¶
last1.id webhook drainer¶
-- queue depth
select status, count(*) from webhook_deliveries group by status;
-- most recent failures
select id, app_client_id, event_type, status, attempts, last_error,
last_response_status, next_attempt_at
from webhook_deliveries
where status in ('pending', 'failed', 'dead_lettered')
order by created_at desc
limit 10;
RadioCheck inbound webhook idempotency¶
-- against RadioCheck Supabase
select delivery_id, event_id, event_type, action, identity_id, received_at, last_error
from last1_webhook_deliveries
order by received_at desc
limit 10;
-- delivery_id == X-Last1-Delivery header. Re-deliveries collide on
-- the PK and short-circuit via INSERT ... ON CONFLICT DO NOTHING.
LastVet inbound webhook idempotency¶
-- against LastVet Postgres
select event_id, event_type, processed_at, error
from last1_webhook_audit
order by processed_at desc
limit 10;
Re-fire a stuck delivery¶
If webhook_deliveries has a row stuck in failed or dead_lettered,
the drainer will not retry on its own (terminal state). To force an
immediate retry:
update webhook_deliveries
set status = 'pending', next_attempt_at = now(), attempts = 0
where id = '<DELIVERY-ID>';
6. Cross-app trust activity (Identity alignment)¶
Verifies the unified activity story: partner events land in last1.id,
the dashboard trust summary reflects them, and optional public dimension
sharing works. Run after deploying last1.id identity-alignment PRs,
Last1 trust-event outbox, and RadioCheck check-in-proof-events updates.
Prerequisites¶
- [ ] last1.id migration
20260602180000_public_trust_dimensions.sqlapplied - [ ] Last1 migration
20260615_last1_trust_event_outbox.sqlapplied - [ ] Last1
LAST1_APP_SECRETmatches last1.idapp_clientsrow forlast1 - [ ] Last1 cron or manual drain:
POST /api/internal/cron/drain-last1-trust-events(with internal auth) - [ ] RadioCheck
check-in-proof-eventsEdge Function redeployed withradio_check_responder_added
6.1 RadioCheck check-in → Activity + Care Connections + dimensions¶
Follow §1.1 to link, then §1.2 for check-in verification. Additionally:
- [ ] Open
last1.idDashboard — Trust summary shows responsiveness and/or participation > 0 - [ ] Open Care Connections — RadioCheck card shows event count ≥ 1; timeline lists check-in with human-readable label
- [ ] Open Activity — filter Partner activity + RadioCheck; check-in event appears with category
trust
6.2 RadioCheck responder added¶
- [ ] Add a responder to a check-in in RadioCheck (any flow that emits
radio_check_responder_added) - [ ] Wait up to 5 min for proof drainer
- [ ] Verify event in Activity: kind
radio_check_responder_added, metadata includesresponder_roleonly (no free text)
-- against last1.id Supabase
select event_type, metadata
from trust_events
where identity_id = '<YOUR-IDENTITY-ID>'
and app_id = 'radiocheck'
and event_type = 'radio_check_responder_added'
order by created_at desc
limit 1;
-- expected: metadata has responder_role; must NOT contain response_message
6.3 Last1.app operational events¶
Requires a Last1 user whose beneficiary profile has last1_identity_id set (linked via Last1 ID OAuth).
- [ ] Enroll beneficiary in a program (org admin) OR record an outcome / confirm validation
- [ ] Drain outbox:
curl -X POST "$LAST1_API_BASE/api/internal/cron/drain-last1-trust-events" \
-H "Authorization: Bearer $LAST1_INTERNAL_CRON_SECRET"
- [ ] Verify trust event in last1.id:
select event_type, app_id, metadata, created_at
from trust_events
where identity_id = '<YOUR-IDENTITY-ID>'
and app_id = 'last1'
order by created_at desc
limit 5;
-- expected: last1.program.enrolled, last1.outcome.recorded, last1.validation.passed/failed, or last1.org.member_joined
- [ ] Dashboard trust summary updates after snapshot refresh (sign out/in or wait for lazy recompute on fetch)
6.4 Public dimension sharing¶
- [ ] Settings → Public profile → Trust dimensions — enable Participation (or another dimension with a non-zero value)
- [ ] Visit
/id/<username>in a private window — dimension bar visible for opted-in key only - [ ] Disable all dimension toggles — public profile hides trust summary bars (credentials unchanged)
6.5 Activity filters¶
- [ ] Activity → All categories → Partner activity — only trust-category rows
- [ ] Activity → RadioCheck — only RadioCheck-sourced events
- [ ] Verifications only — credential and verification events only (no check-ins)
7. Cleanup¶
After running these smokes, clean up your test data so the next run starts fresh:
-- against last1.id Supabase
delete from oauth_refresh_tokens where identity_id = '<TEST-IDENTITY-ID>';
delete from consent_grants where identity_id = '<TEST-IDENTITY-ID>';
delete from identity_app_links where identity_id = '<TEST-IDENTITY-ID>';
delete from webhook_deliveries
where payload->'data'->>'identity_id' = '<TEST-IDENTITY-ID>';
delete from trust_events where identity_id = '<TEST-IDENTITY-ID>';
delete from identities where id = '<TEST-IDENTITY-ID>';
delete from auth.users where email = 'YOUR-TEST-EMAIL@last1-test.local';