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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user