From 6d119f3c3763ce0da935de40475f421563ae342c Mon Sep 17 00:00:00 2001 From: important-new Date: Tue, 23 Jun 2026 00:25:25 +0800 Subject: [PATCH] feat(integration): tenants by-email lookup for cross-tenant client magic-links (P4) Add GET /api/integration/tenants/by-email (M2M-guarded, SaaS-seam only) returning slugs of tenants with a live client/co_client access grant for an email. Enables a portal-side 'find my report' fan-out without a cross-tenant session layer. Standalone unaffected (workers/app.ts APP_MODE==='saas' 404 gate); reads core tables read-only. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Cskv5vjBjxKNj7zeWKH2ue --- server/portal/integration.routes.ts | 46 ++++++++++++++++- .../api/integration-tenants-by-email.spec.ts | 51 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 tests/unit/api/integration-tenants-by-email.spec.ts diff --git a/server/portal/integration.routes.ts b/server/portal/integration.routes.ts index b60b5eca3..0a2e6ac77 100644 --- a/server/portal/integration.routes.ts +++ b/server/portal/integration.routes.ts @@ -1,13 +1,13 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { drizzle } from 'drizzle-orm/d1'; -import { eq, isNotNull } from 'drizzle-orm'; +import { eq, isNotNull, and, inArray, isNull, or, gt } from 'drizzle-orm'; import { HonoConfig } from '../types/hono'; import { TenantUpdateParams } from '../lib/integration'; import { TenantStatusBodySchema, SeedStarterContentBodySchema } from '../lib/validations/admin.schema'; import { SyncQuotaSchema } from '../lib/validations/sync-quota.schema'; import { logger } from '../lib/logger'; -import { tenantConfigs } from '../lib/db/schema'; +import { tenantConfigs, inspectionAccessTokens, tenants } from '../lib/db/schema'; import { reencryptAllTenantSecrets } from '../lib/secrets-reencrypt'; import { secretsCacheKey } from '../lib/secrets-cache'; import { OutboxService } from './outbox.service'; @@ -333,4 +333,46 @@ api.get('/usage', requireServiceBinding, async (c) => { } }); +/** + * GET /api/integration/tenants/by-email?email= + * Cross-tenant client grant lookup: returns the slugs of tenants where the + * email holds a LIVE (not revoked, not expired) client/co_client access grant. + * Platform-level read (raw drizzle, no tenant scope) — guarded by + * requireServiceBinding. Enables a portal-side "find my report" fan-out that + * triggers each tenant's own magic-link without a cross-tenant session layer. + */ +api.get('/tenants/by-email', requireServiceBinding, async (c) => { + const email = c.req.query('email'); + if (!email || !email.includes('@')) { + return c.json({ success: false, error: { message: 'email required' } }, 400); + } + try { + const d = drizzle(c.env.DB); + const now = Date.now(); + + const grants = await d + .select({ tenantId: inspectionAccessTokens.tenantId }) + .from(inspectionAccessTokens) + .where(and( + eq(inspectionAccessTokens.recipientEmail, email), + inArray(inspectionAccessTokens.role, ['client', 'co_client']), + isNull(inspectionAccessTokens.revokedAt), + or(isNull(inspectionAccessTokens.expiresAt), gt(inspectionAccessTokens.expiresAt, now)), + )); + + const tenantIds = [...new Set(grants.map((g) => g.tenantId as string))]; + if (tenantIds.length === 0) return c.json({ success: true, data: { slugs: [] } }); + + const rows = await d + .select({ slug: tenants.slug }) + .from(tenants) + .where(inArray(tenants.id, tenantIds)); + + return c.json({ success: true, data: { slugs: rows.map((r) => r.slug as string) } }); + } catch (error: unknown) { + logger.error('tenants by-email lookup failed', {}, error instanceof Error ? error : undefined); + return c.json({ success: false, error: { message: 'Internal server error' } }, 500); + } +}); + export default api; diff --git a/tests/unit/api/integration-tenants-by-email.spec.ts b/tests/unit/api/integration-tenants-by-email.spec.ts new file mode 100644 index 000000000..5baa3a9d4 --- /dev/null +++ b/tests/unit/api/integration-tenants-by-email.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; +import { createTestDb, setupSchema } from '../db'; +import type { HonoConfig } from '../../../server/types/hono'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; +import integrationRoutes from '../../../server/portal/integration.routes'; +import { signM2mHeader, M2M_HEADER } from '../../../server/lib/m2m-auth'; + +const FAKE_PEM = `-----BEGIN PRIVATE KEY-----\n${btoa('test-m2m-shared-key-material-0123456789')}\n-----END PRIVATE KEY-----`; +const ENV = { DB: {}, JWT_CURRENT_KID: 'v1', JWT_PRIVATE_KEY_V1: FAKE_PEM } as Record; + +describe('GET /api/integration/tenants/by-email', () => { + let testDb: BetterSQLite3Database; + let sqlite: ReturnType['sqlite']; + + function app() { const a = new OpenAPIHono(); a.route('/api/integration', integrationRoutes); return a; } + async function header() { return signM2mHeader(ENV as Record); } + + beforeEach(async () => { + const s = createTestDb(); testDb = s.db; sqlite = s.sqlite; await setupSchema(sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(testDb); + await testDb.insert(schema.tenants).values([ + { id: 't1', name: 'Acme', slug: 'acme', createdAt: new Date() }, + { id: 't2', name: 'Beta', slug: 'beta', createdAt: new Date() }, + ] as never); + await testDb.insert(schema.inspectionAccessTokens).values([ + { id: 'g1', tenantId: 't1', inspectionId: 'i1', recipientEmail: 'jane@x.com', role: 'client', token: 'tok1', createdAt: Date.now() }, + { id: 'g2', tenantId: 't2', inspectionId: 'i2', recipientEmail: 'jane@x.com', role: 'co_client', token: 'tok2', createdAt: Date.now(), revokedAt: Date.now() }, // revoked → excluded + ] as never); + }); + afterEach(() => { sqlite.close(); vi.clearAllMocks(); }); + + it('403 without M2M header', async () => { + const res = await app().request('/api/integration/tenants/by-email?email=jane@x.com', {}, ENV); + expect(res.status).toBe(403); + }); + it('returns only tenants with a LIVE grant', async () => { + const res = await app().request('/api/integration/tenants/by-email?email=jane@x.com', { headers: { [M2M_HEADER]: await header() } }, ENV); + expect(res.status).toBe(200); + const body = await res.json() as { data: { slugs: string[] } }; + expect(body.data.slugs).toEqual(['acme']); // t2 grant revoked + }); + it('400 on missing email', async () => { + const res = await app().request('/api/integration/tenants/by-email', { headers: { [M2M_HEADER]: await header() } }, ENV); + expect(res.status).toBe(400); + }); +});