feat(consents): auto-grant data:<provider> on connect; remove agent: consents (ADR-0015)

- integrations.ts: grant data:<provider> on OAuth callback, revoke on disconnect
- Backfill migration: INSERT OR IGNORE data:<provider> for all active tokens
- Agent manifests: drop agent:<id> from required_consents (momentum, time-of-day,
  overdue-task, recent-patterns, health-vitals) — per-agent control is a preference
- eligibility.ts: update comment to reflect data:-only consent model
- test_manifest.py: assert no agent: consents remain in any manifest
- migrations.test.ts: backfill idempotency tests for issue #127
- Dockerfile.api: drop --offline flag (fixes ERR_PNPM_NO_OFFLINE_META)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 15:09:58 +00:00
parent 34925310cf
commit 772bb6e194
11 changed files with 124 additions and 9 deletions

View File

@@ -85,3 +85,45 @@ describe('runMigrations — idempotency', () => {
});
});
describe('runMigrations — issue #127 backfill', () => {
it('grants data:<provider> consent for existing active integration tokens', () => {
const sqlite = freshDb();
runMigrations(sqlite);
// Seed a user + active Todoist token (simulates pre-#127 state)
sqlite.exec(`
INSERT INTO users (id, email, role, created_at) VALUES ('u2', 'u2@test.com', 'user', '2026-01-01T00:00:00Z');
INSERT INTO user_consents (user_id, consent_key, granted_at) VALUES ('u2', 'data:core', '2026-01-01T00:00:00Z');
INSERT INTO integration_tokens (id, user_id, provider, access_token, token_status, connected_at)
VALUES ('tok1', 'u2', 'todoist', 'secret', 'active', '2026-01-02T00:00:00Z');
`);
// Re-run migrations — the backfill should insert data:todoist
runMigrations(sqlite);
const rows = sqlite
.prepare(`SELECT consent_key FROM user_consents WHERE user_id = 'u2' ORDER BY consent_key`)
.all() as { consent_key: string }[];
expect(rows.map((r) => r.consent_key)).toEqual(['data:core', 'data:todoist']);
});
it('is idempotent — running twice does not duplicate consent rows', () => {
const sqlite = freshDb();
runMigrations(sqlite);
sqlite.exec(`
INSERT INTO users (id, email, role, created_at) VALUES ('u3', 'u3@test.com', 'user', '2026-01-01T00:00:00Z');
INSERT INTO integration_tokens (id, user_id, provider, access_token, token_status, connected_at)
VALUES ('tok2', 'u3', 'todoist', 'secret', 'active', '2026-01-02T00:00:00Z');
`);
runMigrations(sqlite);
runMigrations(sqlite);
const count = (sqlite
.prepare(`SELECT COUNT(*) as n FROM user_consents WHERE user_id = 'u3' AND consent_key = 'data:todoist'`)
.get() as { n: number }).n;
expect(count).toBe(1);
});
});

View File

@@ -1,8 +1,10 @@
/**
* Registry-driven agent eligibility filter (ADR-0014 step 5).
* Registry-driven agent eligibility filter (ADR-0014 step 5, updated by ADR-0015).
*
* Rules (all must pass for an agent to be eligible):
* 1. All required_consents are granted and not revoked.
* 1. Every data:<source> in required_consents is granted and not revoked.
* Consent is granted automatically when the user connects that data source.
* agent:<id> consents no longer exist — per-agent control is a preference (rule 3).
* 2. No silenced_in_contexts entry matches an active context.
* 3. user_preferences[scope='agent:<id>', key='enabled'] is not false.
*

View File

@@ -1,7 +1,7 @@
import { type Router as ExpressRouter, Router, Request, Response } from 'express';
import { nanoid } from 'nanoid';
import { db } from '../db/index.js';
import { integrationTokens } from '../db/schema.js';
import { integrationTokens, userConsents } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { config } from '../config.js';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
@@ -33,6 +33,28 @@ const GOOGLE_HEALTH_SCOPES = [
// In-memory CSRF state store
const pendingStates = new Map<string, { userId: string; redirectTo: string }>();
async function grantDataSourceConsent(userId: string, provider: string): Promise<void> {
const consentKey = `data:${provider}`;
const now = new Date().toISOString();
await db.insert(userConsents)
.values({ userId, consentKey, grantedAt: now, revokedAt: null })
.onConflictDoUpdate({
target: [userConsents.userId, userConsents.consentKey],
set: { grantedAt: now, revokedAt: null },
});
}
async function revokeDataSourceConsent(userId: string, provider: string): Promise<void> {
const consentKey = `data:${provider}`;
const now = new Date().toISOString();
await db.insert(userConsents)
.values({ userId, consentKey, grantedAt: now, revokedAt: now })
.onConflictDoUpdate({
target: [userConsents.userId, userConsents.consentKey],
set: { revokedAt: now },
});
}
/** GET /api/integrations — list connected integrations */
router.get('/', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
const tokens = await db
@@ -118,6 +140,7 @@ router.get('/todoist/callback', async (req: Request, res: Response) => {
tokenStatus: 'active',
connectedAt: now,
});
await grantDataSourceConsent(pending.userId, 'todoist');
res.redirect(`${config.WEB_BASE_URL}${pending.redirectTo}?connected=todoist`);
});
@@ -208,6 +231,7 @@ router.get('/google-health/callback', async (req: Request, res: Response) => {
tokenStatus: 'active',
connectedAt: now.toISOString(),
});
await grantDataSourceConsent(pending.userId, 'google-health');
res.redirect(`${config.WEB_BASE_URL}${pending.redirectTo}?connected=google-health`);
});
@@ -238,6 +262,8 @@ router.delete('/:provider', requireAuth, async (req: AuthenticatedRequest, res:
await fetch(`${GOOGLE_REVOKE_URL}?token=${token.accessToken}`, { method: 'POST' }).catch(() => {});
}
await revokeDataSourceConsent(req.userId!, provider);
await db
.delete(integrationTokens)
.where(