diff --git a/apps/habit-service/migrations/0002_data_rights_erasure.sql b/apps/habit-service/migrations/0002_data_rights_erasure.sql new file mode 100644 index 00000000..59a8f035 --- /dev/null +++ b/apps/habit-service/migrations/0002_data_rights_erasure.sql @@ -0,0 +1,81 @@ +BEGIN; + +CREATE TABLE habit.data_rights_erasure_receipts ( + workspace_id uuid NOT NULL, + idempotency_key uuid NOT NULL, + request_id uuid NOT NULL, + requested_by_user_id uuid NOT NULL, + erased_records integer NOT NULL, + receipt_sha256 text NOT NULL, + erased_at timestamptz NOT NULL, + CONSTRAINT data_rights_erasure_receipts_primary + PRIMARY KEY (workspace_id, idempotency_key), + CONSTRAINT data_rights_erasure_receipts_workspace_uuid_v4 CHECK ( + workspace_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT data_rights_erasure_receipts_idempotency_uuid_v4 CHECK ( + idempotency_key::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT data_rights_erasure_receipts_request_uuid_v4 CHECK ( + request_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT data_rights_erasure_receipts_user_uuid_v4 CHECK ( + requested_by_user_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT data_rights_erasure_receipts_count_nonnegative CHECK ( + erased_records >= 0 + ), + CONSTRAINT data_rights_erasure_receipts_digest_sha256 CHECK ( + receipt_sha256 ~ '^[0-9a-f]{64}$' + ) +); + +COMMENT ON TABLE habit.data_rights_erasure_receipts IS + 'Replay evidence for explicitly authorized Habit-owned data-rights erasure.'; + +CREATE FUNCTION habit.erase_workspace_data(target_workspace_id uuid) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, habit +AS $$ +DECLARE + deleted_completion_events integer := 0; + deleted_habit_definitions integer := 0; +BEGIN + IF target_workspace_id IS NULL OR target_workspace_id::text !~ + '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + THEN + RAISE EXCEPTION USING + ERRCODE = '22023', + MESSAGE = 'Habit erasure workspace identifier is invalid'; + END IF; + + -- Completion history remains append-only for ordinary callers. This bounded, + -- owner-executed erasure function is the only reviewed path that temporarily + -- disables the row mutation trigger, and PostgreSQL transactionality restores + -- the trigger state together with data if any statement fails. + ALTER TABLE habit.completion_events + DISABLE TRIGGER completion_events_append_only; + + DELETE FROM habit.completion_events + WHERE workspace_id = target_workspace_id; + GET DIAGNOSTICS deleted_completion_events = ROW_COUNT; + + ALTER TABLE habit.completion_events + ENABLE TRIGGER completion_events_append_only; + + DELETE FROM habit.habit_definitions + WHERE workspace_id = target_workspace_id; + GET DIAGNOSTICS deleted_habit_definitions = ROW_COUNT; + + RETURN deleted_completion_events + deleted_habit_definitions; +END; +$$; + +REVOKE ALL ON FUNCTION habit.erase_workspace_data(uuid) FROM PUBLIC; + +COMMENT ON FUNCTION habit.erase_workspace_data(uuid) IS + 'Owner-authorized Habit data-rights erasure; runtime roles require an explicit EXECUTE grant.'; + +COMMIT; diff --git a/apps/habit-service/src/habit-data-rights.integration.test.ts b/apps/habit-service/src/habit-data-rights.integration.test.ts new file mode 100644 index 00000000..3fe4fe6c --- /dev/null +++ b/apps/habit-service/src/habit-data-rights.integration.test.ts @@ -0,0 +1,245 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { Habit, HabitCompletionEvent } from './habit-domain'; +import { HabitDataRightsError } from './habit-data-rights'; +import { createHabitRuntime, type HabitRuntime } from './habit-runtime'; +import { + type HabitSqlClient, + type HabitSqlQueryResult, + PostgresHabitRepository, +} from './postgres-habit-repository'; + +const DATABASE_URL = process.env.HABIT_DATABASE_URL; +const describeWithPostgres = DATABASE_URL ? describe : describe.skip; +let administrativePool: Pool; + +class PoolSqlClient implements HabitSqlClient { + constructor(private readonly pool: Pool) {} + + async query( + text: string, + values: readonly unknown[], + ): Promise> { + const result = await this.pool.query(text, [...values]); + return { rows: result.rows as Row[] }; + } +} + +function requireDatabaseUrl(): string { + if (!DATABASE_URL) { + throw new Error('HABIT_DATABASE_URL is required for integration tests'); + } + return DATABASE_URL; +} + +async function applyMigrations(pool: Pool): Promise { + for (const migration of [ + '0001_recurring_habit_core.sql', + '0002_data_rights_erasure.sql', + ]) { + const sql = await readFile(resolve(__dirname, '../migrations', migration), 'utf8'); + await pool.query(sql); + } +} + +function repository(pool: Pool): PostgresHabitRepository { + return new PostgresHabitRepository(new PoolSqlClient(pool)); +} + +function habit(workspaceId: string, title: string): Habit { + return { + id: randomUUID(), + workspaceId, + title, + timezone: 'Asia/Seoul', + startsOn: '2026-08-01', + recurrence: { kind: 'weekly', interval: 1, weekdays: [1, 3, 5] }, + createdAt: '2026-08-01T00:00:00.000Z', + }; +} + +function completion(storedHabit: Habit): HabitCompletionEvent { + return { + id: randomUUID(), + workspaceId: storedHabit.workspaceId, + habitId: storedHabit.id, + scheduledLocalDate: '2026-08-10', + completedAt: '2026-08-10T12:00:00.000Z', + idempotencyKey: randomUUID(), + recordedAt: '2026-08-10T12:00:01.000Z', + }; +} + +async function expectAppendOnlyDeleteRejected( + pool: Pool, + workspaceId: string, +): Promise { + await expect( + pool.query('DELETE FROM habit.completion_events WHERE workspace_id = $1', [ + workspaceId, + ]), + ).rejects.toMatchObject({ code: '55000' }); +} + +describeWithPostgres('Habit data-rights PostgreSQL integration', () => { + beforeAll(async () => { + administrativePool = new Pool({ + connectionString: requireDatabaseUrl(), + application_name: 'life-os-habit-data-rights-admin', + max: 6, + }); + }); + + beforeEach(async () => { + await administrativePool.query('DROP SCHEMA IF EXISTS habit CASCADE'); + await applyMigrations(administrativePool); + }); + + afterAll(async () => { + await administrativePool.query('DROP SCHEMA IF EXISTS habit CASCADE'); + await administrativePool.end(); + }); + + it('exports, erases, replays, verifies, and preserves another tenant', async () => { + const workspaceId = randomUUID(); + const otherWorkspaceId = randomUUID(); + const requestedByUserId = randomUUID(); + const requestId = randomUUID(); + const idempotencyKey = randomUUID(); + const durableRepository = repository(administrativePool); + const ownedHabit = habit(workspaceId, 'Export and erase this habit'); + const privateHabit = habit(otherWorkspaceId, 'Preserve private tenant habit'); + const ownedCompletion = completion(ownedHabit); + const privateCompletion = completion(privateHabit); + + await durableRepository.saveHabit(ownedHabit); + await durableRepository.saveHabit(privateHabit); + await durableRepository.appendCompletion(ownedCompletion); + await durableRepository.appendCompletion(privateCompletion); + + const runtime: HabitRuntime = createHabitRuntime({ + HABIT_DATABASE_URL: requireDatabaseUrl(), + HABIT_DATABASE_POOL_MAX: '4', + }); + + try { + const firstExport = await runtime.dataRightsContributor.handle({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'export', + workspaceId, + requestedByUserId, + requestId, + }); + expect(firstExport.operation).toBe('export'); + if (firstExport.operation !== 'export') { + throw new Error('Expected a Habit export response'); + } + expect(firstExport.recordCount).toBe(2); + expect(firstExport.schemaVersion).toBe('habit.data-rights.v1'); + expect(firstExport.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(JSON.stringify(firstExport.data)).toContain(ownedHabit.id); + expect(JSON.stringify(firstExport.data)).toContain(ownedCompletion.id); + expect(JSON.stringify(firstExport.data)).not.toContain(privateHabit.id); + expect(JSON.stringify(firstExport.data)).not.toContain(privateCompletion.id); + + const repeatedExport = await runtime.dataRightsContributor.handle({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'export', + workspaceId, + requestedByUserId, + requestId: randomUUID(), + }); + expect(repeatedExport.operation).toBe('export'); + if (repeatedExport.operation !== 'export') { + throw new Error('Expected a repeated Habit export response'); + } + expect(repeatedExport.sha256).toBe(firstExport.sha256); + expect(repeatedExport.data).toEqual(firstExport.data); + + const preflight = await runtime.dataRightsContributor.handle({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'erase_preflight', + workspaceId, + requestedByUserId, + requestId: randomUUID(), + }); + expect(preflight).toMatchObject({ + operation: 'erase_preflight', + ready: true, + blockers: [], + }); + + await expectAppendOnlyDeleteRejected(administrativePool, otherWorkspaceId); + + const erasure = await runtime.dataRightsContributor.handle({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'erase', + workspaceId, + requestedByUserId, + requestId, + idempotencyKey, + }); + expect(erasure.operation).toBe('erase'); + if (erasure.operation !== 'erase') { + throw new Error('Expected a Habit erasure response'); + } + expect(erasure.erasedRecords).toBe(2); + expect(erasure.receiptSha256).toMatch(/^[0-9a-f]{64}$/); + + const replay = await runtime.dataRightsContributor.handle({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'erase', + workspaceId, + requestedByUserId, + requestId, + idempotencyKey, + }); + expect(replay).toEqual(erasure); + + await expect( + runtime.dataRightsContributor.handle({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'erase', + workspaceId, + requestedByUserId, + requestId: randomUUID(), + idempotencyKey, + }), + ).rejects.toBeInstanceOf(HabitDataRightsError); + + const verification = await runtime.dataRightsContributor.handle({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'verify_erased', + workspaceId, + requestedByUserId, + requestId: randomUUID(), + }); + expect(verification).toMatchObject({ + operation: 'verify_erased', + erased: true, + }); + + const privateExport = await runtime.dataRightsContributor.handle({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'export', + workspaceId: otherWorkspaceId, + requestedByUserId, + requestId: randomUUID(), + }); + expect(privateExport.operation).toBe('export'); + if (privateExport.operation !== 'export') { + throw new Error('Expected the preserved tenant export response'); + } + expect(privateExport.recordCount).toBe(2); + expect(JSON.stringify(privateExport.data)).toContain(privateHabit.id); + expect(JSON.stringify(privateExport.data)).toContain(privateCompletion.id); + + await expectAppendOnlyDeleteRejected(administrativePool, otherWorkspaceId); + } finally { + await runtime.close(); + } + }); +}); diff --git a/apps/habit-service/src/habit-data-rights.test.ts b/apps/habit-service/src/habit-data-rights.test.ts new file mode 100644 index 00000000..5ca25312 --- /dev/null +++ b/apps/habit-service/src/habit-data-rights.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { + createHabitRuntime, + type HabitPool, + type HabitPoolConnection, + type HabitRuntime, +} from './habit-runtime'; + +const TEST_DATABASE_URL = ['postgresql:', '', '127.0.0.1', 'habit_test'].join( + '/', +); +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; + +/** Minimal credential-free pool used only to inspect runtime composition. */ +function inertPool(): HabitPool { + const connection: HabitPoolConnection = { + async query(): Promise<{ rows: Row[] }> { + return { rows: [] }; + }, + release(): void {}, + }; + return { + async query(): Promise<{ rows: Row[] }> { + return { rows: [] }; + }, + async connect(): Promise { + return connection; + }, + async end(): Promise {}, + }; +} + +describe('Habit data-rights runtime composition', () => { + it('exposes a service-owned contributor through the production runtime', async () => { + const runtime = createHabitRuntime( + { HABIT_DATABASE_URL: TEST_DATABASE_URL }, + () => inertPool(), + ) as HabitRuntime & { + readonly dataRightsContributor?: { + handle(request: unknown): Promise; + }; + }; + + try { + const contributor = runtime.dataRightsContributor; + expect(contributor).toBeDefined(); + if (!contributor) { + throw new Error('Habit runtime did not compose its data-rights contributor'); + } + + const response = await contributor.handle({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'export', + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, + }); + + expect(response).toMatchObject({ + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'export', + contributor: 'habit.service', + requestId: REQUEST_ID, + recordCount: 0, + }); + } finally { + await runtime.close(); + } + }); +}); diff --git a/apps/habit-service/src/habit-data-rights.ts b/apps/habit-service/src/habit-data-rights.ts new file mode 100644 index 00000000..c4e72148 --- /dev/null +++ b/apps/habit-service/src/habit-data-rights.ts @@ -0,0 +1,592 @@ +import { createHash } from 'node:crypto'; +import type { + HabitSqlClient, + HabitSqlQueryResult, +} from './postgres-habit-repository'; + +/** Must remain byte-for-byte aligned with packages/contracts/src/data-rights.ts. */ +export const DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION = + 'life-os.data-rights-contributor.v1' as const; +const CONTRIBUTOR_NAME = 'habit.service' as const; +const EXPORT_SCHEMA_VERSION = 'habit.data-rights.v1' as const; +const EXPORT_PAGE_SIZE = 1_000; +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SHA_256_PATTERN = /^[0-9a-f]{64}$/; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +/** Transaction-capable SQL boundary required by destructive data-rights work. */ +export interface HabitTransactionalSqlClient extends HabitSqlClient { + transaction(operation: (client: HabitSqlClient) => Promise): Promise; +} + +/** JSON value emitted by the Habit-owned data-rights contributor. */ +export type HabitDataRightsJsonValue = + | boolean + | number + | string + | null + | readonly HabitDataRightsJsonValue[] + | { readonly [key: string]: HabitDataRightsJsonValue }; + +interface RequestBase { + readonly contractVersion: typeof DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION; + readonly workspaceId: string; + readonly requestedByUserId: string; + readonly requestId: string; +} + +/** Authorized request accepted by the Habit-owned contributor. */ +export type HabitDataRightsRequest = RequestBase & + ( + | { readonly operation: 'export' } + | { readonly operation: 'erase_preflight' } + | { readonly operation: 'erase'; readonly idempotencyKey: string } + | { readonly operation: 'verify_erased' } + ); + +interface ResponseBase { + readonly contractVersion: typeof DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION; + readonly contributor: typeof CONTRIBUTOR_NAME; + readonly requestId: string; +} + +/** Successful Habit-owned evidence returned to the Identity orchestrator. */ +export type HabitDataRightsResponse = ResponseBase & + ( + | { + readonly operation: 'export'; + readonly schemaVersion: typeof EXPORT_SCHEMA_VERSION; + readonly recordCount: number; + readonly sha256: string; + readonly data: HabitDataRightsJsonValue; + } + | { + readonly operation: 'erase_preflight'; + readonly ready: boolean; + readonly blockers: readonly string[]; + } + | { + readonly operation: 'erase'; + readonly erasedRecords: number; + readonly receiptSha256: string; + } + | { + readonly operation: 'verify_erased'; + readonly erased: boolean; + readonly evidenceSha256: string; + } + ); + +interface HabitDefinitionExportRow { + id: unknown; + title: unknown; + timezone_name: unknown; + recurrence_kind: unknown; + recurrence_interval: unknown; + weekday_mask: unknown; + starts_on: unknown; + created_at: unknown; +} + +interface CompletionEventExportRow { + id: unknown; + habit_id: unknown; + scheduled_local_date: unknown; + completed_at: unknown; + idempotency_key: unknown; + recorded_at: unknown; +} + +interface PrivilegeRow { + erasure_receipts_ready: unknown; + erasure_function_ready: unknown; +} + +interface CountRow { + record_count: unknown; +} + +interface ReceiptRow { + requested_by_user_id: unknown; + request_id: unknown; + erased_records: unknown; + receipt_sha256: unknown; +} + +/** Stable credential-free failure for malformed requests or persisted evidence. */ +export class HabitDataRightsError extends Error { + constructor(message = 'Habit data-rights operation failed validation') { + super(message); + this.name = 'HabitDataRightsError'; + } +} + +function requireUuidV4(value: unknown, field: string): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + throw new HabitDataRightsError(`${field} must be a UUIDv4`); + } + return value.toLowerCase(); +} + +function requireString( + value: unknown, + field: string, + maximumLength = 10_000, +): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maximumLength + ) { + throw new HabitDataRightsError(`${field} is invalid`); + } + return value; +} + +function requireTimestamp(value: unknown, field: string): string { + const parsed = + value instanceof Date ? value : new Date(requireString(value, field)); + if (Number.isNaN(parsed.getTime())) { + throw new HabitDataRightsError(`${field} is invalid`); + } + return parsed.toISOString(); +} + +function requireDate(value: unknown, field: string): string { + const candidate = + value instanceof Date + ? value.toISOString().slice(0, 10) + : requireString(value, field, 10); + if (!DATE_PATTERN.test(candidate)) { + throw new HabitDataRightsError(`${field} is invalid`); + } + const parsed = new Date(`${candidate}T00:00:00.000Z`); + if ( + Number.isNaN(parsed.getTime()) || + parsed.toISOString().slice(0, 10) !== candidate + ) { + throw new HabitDataRightsError(`${field} is invalid`); + } + return candidate; +} + +function requireInteger( + value: unknown, + field: string, + minimum: number, + maximum = Number.MAX_SAFE_INTEGER, +): number { + const numeric = typeof value === 'string' ? Number(value) : value; + if ( + typeof numeric !== 'number' || + !Number.isSafeInteger(numeric) || + numeric < minimum || + numeric > maximum + ) { + throw new HabitDataRightsError(`${field} is invalid`); + } + return numeric; +} + +function requireBoolean(value: unknown, field: string): boolean { + if (typeof value !== 'boolean') { + throw new HabitDataRightsError(`${field} is invalid`); + } + return value; +} + +function requireSha256(value: unknown): string { + const candidate = requireString(value, 'sha256', 64).toLowerCase(); + if (!SHA_256_PATTERN.test(candidate)) { + throw new HabitDataRightsError('sha256 is invalid'); + } + return candidate; +} + +function requireTimezone(value: unknown): string { + const timezone = requireString(value, 'timezone_name', 255); + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format( + new Date(0), + ); + } catch { + throw new HabitDataRightsError('timezone_name is invalid'); + } + return timezone; +} + +function canonicalJson(value: HabitDataRightsJsonValue): string { + if (value === null) return 'null'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'boolean' || typeof value === 'number') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`; + } + return `{${Object.entries(value) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(',')}}`; +} + +function digest(value: HabitDataRightsJsonValue): string { + return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex'); +} + +function normalizeRequest(request: HabitDataRightsRequest): { + readonly request: HabitDataRightsRequest; + readonly workspaceId: string; + readonly requestedByUserId: string; + readonly requestId: string; +} { + if ( + !request || + request.contractVersion !== DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION + ) { + throw new HabitDataRightsError('Contributor contract version is invalid'); + } + if ( + request.operation !== 'export' && + request.operation !== 'erase_preflight' && + request.operation !== 'erase' && + request.operation !== 'verify_erased' + ) { + throw new HabitDataRightsError('Contributor operation is invalid'); + } + return { + request, + workspaceId: requireUuidV4(request.workspaceId, 'workspaceId'), + requestedByUserId: requireUuidV4( + request.requestedByUserId, + 'requestedByUserId', + ), + requestId: requireUuidV4(request.requestId, 'requestId'), + }; +} + +async function collectRows( + client: HabitSqlClient, + query: string, + workspaceId: string, +): Promise { + const rows: Row[] = []; + let offset = 0; + for (;;) { + const page = await client.query(query, [ + workspaceId, + EXPORT_PAGE_SIZE, + offset, + ]); + rows.push(...page.rows); + if (page.rows.length < EXPORT_PAGE_SIZE) { + return rows; + } + offset += page.rows.length; + } +} + +function normalizeHabitDefinition( + row: HabitDefinitionExportRow, +): HabitDataRightsJsonValue { + const recurrenceKind = requireString(row.recurrence_kind, 'recurrence_kind', 16); + if (recurrenceKind !== 'daily' && recurrenceKind !== 'weekly') { + throw new HabitDataRightsError('recurrence_kind is invalid'); + } + const weekdayMask = requireInteger(row.weekday_mask, 'weekday_mask', 0, 127); + if ( + (recurrenceKind === 'daily' && weekdayMask !== 0) || + (recurrenceKind === 'weekly' && weekdayMask === 0) + ) { + throw new HabitDataRightsError('weekday_mask is invalid'); + } + return Object.freeze({ + id: requireUuidV4(row.id, 'habit_id'), + title: requireString(row.title, 'title', 160), + timezoneName: requireTimezone(row.timezone_name), + recurrenceKind, + recurrenceInterval: requireInteger( + row.recurrence_interval, + 'recurrence_interval', + 1, + 365, + ), + weekdayMask, + startsOn: requireDate(row.starts_on, 'starts_on'), + createdAt: requireTimestamp(row.created_at, 'created_at'), + }); +} + +function normalizeCompletionEvent( + row: CompletionEventExportRow, +): HabitDataRightsJsonValue { + return Object.freeze({ + id: requireUuidV4(row.id, 'completion_id'), + habitId: requireUuidV4(row.habit_id, 'habit_id'), + scheduledLocalDate: requireDate( + row.scheduled_local_date, + 'scheduled_local_date', + ), + completedAt: requireTimestamp(row.completed_at, 'completed_at'), + idempotencyKey: requireUuidV4(row.idempotency_key, 'idempotency_key'), + recordedAt: requireTimestamp(row.recorded_at, 'recorded_at'), + }); +} + +/** Concrete Habit-owned implementation of life-os.data-rights-contributor.v1. */ +export class HabitDataRightsContributor { + constructor(private readonly client: HabitTransactionalSqlClient) {} + + /** Validates one request before dispatching only to Habit-owned persistence. */ + async handle( + untrustedRequest: HabitDataRightsRequest, + ): Promise { + const { request, workspaceId, requestedByUserId, requestId } = + normalizeRequest(untrustedRequest); + switch (request.operation) { + case 'export': + return await this.exportWorkspace(workspaceId, requestId); + case 'erase_preflight': + return await this.preflightErase(requestId); + case 'erase': + return await this.eraseWorkspace( + workspaceId, + requestedByUserId, + requestId, + requireUuidV4(request.idempotencyKey, 'idempotencyKey'), + ); + case 'verify_erased': + return await this.verifyWorkspaceErased(workspaceId, requestId); + } + } + + private async exportWorkspace( + workspaceId: string, + requestId: string, + ): Promise { + return await this.client.transaction(async (transaction) => { + await transaction.query( + 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY', + [], + ); + const habits = await collectRows( + transaction, + `SELECT id, title, timezone_name, recurrence_kind, + recurrence_interval, weekday_mask, starts_on, created_at + FROM habit.habit_definitions + WHERE workspace_id = $1 + ORDER BY created_at ASC, id ASC + LIMIT $2 OFFSET $3`, + workspaceId, + ); + const completions = await collectRows( + transaction, + `SELECT id, habit_id, scheduled_local_date, completed_at, + idempotency_key, recorded_at + FROM habit.completion_events + WHERE workspace_id = $1 + ORDER BY recorded_at ASC, id ASC + LIMIT $2 OFFSET $3`, + workspaceId, + ); + const data = Object.freeze({ + habits: Object.freeze(habits.map(normalizeHabitDefinition)), + completionEvents: Object.freeze(completions.map(normalizeCompletionEvent)), + }); + return { + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: 'export', + contributor: CONTRIBUTOR_NAME, + requestId, + schemaVersion: EXPORT_SCHEMA_VERSION, + recordCount: habits.length + completions.length, + sha256: digest(data), + data, + }; + }); + } + + private async readPrivileges(client: HabitSqlClient): Promise<{ + readonly receiptsReady: boolean; + readonly functionReady: boolean; + }> { + const result = await client.query( + `SELECT + COALESCE( + has_table_privilege( + current_user, + to_regclass('habit.data_rights_erasure_receipts'), + 'SELECT,INSERT' + ), + false + ) AS erasure_receipts_ready, + COALESCE( + has_function_privilege( + current_user, + to_regprocedure('habit.erase_workspace_data(uuid)'), + 'EXECUTE' + ), + false + ) AS erasure_function_ready`, + [], + ); + const row = result.rows[0]; + return { + receiptsReady: requireBoolean( + row?.erasure_receipts_ready, + 'erasure_receipts_ready', + ), + functionReady: requireBoolean( + row?.erasure_function_ready, + 'erasure_function_ready', + ), + }; + } + + private async preflightErase( + requestId: string, + ): Promise { + const privileges = await this.readPrivileges(this.client); + const blockers: string[] = []; + if (!privileges.receiptsReady) { + blockers.push('habit_erasure_receipt_privileges_unavailable'); + } + if (!privileges.functionReady) { + blockers.push('habit_erasure_function_unavailable'); + } + return { + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: 'erase_preflight', + contributor: CONTRIBUTOR_NAME, + requestId, + ready: blockers.length === 0, + blockers: Object.freeze(blockers), + }; + } + + private async eraseWorkspace( + workspaceId: string, + requestedByUserId: string, + requestId: string, + idempotencyKey: string, + ): Promise { + return await this.client.transaction(async (transaction) => { + const privileges = await this.readPrivileges(transaction); + if (!privileges.receiptsReady || !privileges.functionReady) { + throw new HabitDataRightsError('Habit erasure authority is unavailable'); + } + + await transaction.query( + `SELECT pg_advisory_xact_lock( + hashtextextended($1::text, 0) + )`, + [`${CONTRIBUTOR_NAME}:${workspaceId}:${idempotencyKey}`], + ); + + const existing = await transaction.query( + `SELECT requested_by_user_id, request_id, erased_records, receipt_sha256 + FROM habit.data_rights_erasure_receipts + WHERE workspace_id = $1 AND idempotency_key = $2`, + [workspaceId, idempotencyKey], + ); + if (existing.rows.length > 0) { + const receipt = existing.rows[0]; + if ( + requireUuidV4(receipt?.requested_by_user_id, 'requested_by_user_id') !== + requestedByUserId || + requireUuidV4(receipt?.request_id, 'request_id') !== requestId + ) { + throw new HabitDataRightsError('Habit erasure replay identity conflicts'); + } + return { + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: 'erase', + contributor: CONTRIBUTOR_NAME, + requestId, + erasedRecords: requireInteger( + receipt?.erased_records, + 'erased_records', + 0, + ), + receiptSha256: requireSha256(receipt?.receipt_sha256), + }; + } + + const erased = await transaction.query( + `SELECT habit.erase_workspace_data($1::uuid) AS record_count`, + [workspaceId], + ); + const erasedRecords = requireInteger( + erased.rows[0]?.record_count, + 'record_count', + 0, + ); + const receiptEvidence = Object.freeze({ + contributor: CONTRIBUTOR_NAME, + workspaceId, + idempotencyKey, + requestId, + requestedByUserId, + erasedRecords, + }); + const receiptSha256 = digest(receiptEvidence); + await transaction.query( + `INSERT INTO habit.data_rights_erasure_receipts ( + workspace_id, + idempotency_key, + request_id, + requested_by_user_id, + erased_records, + receipt_sha256, + erased_at + ) VALUES ($1, $2, $3, $4, $5, $6, transaction_timestamp())`, + [ + workspaceId, + idempotencyKey, + requestId, + requestedByUserId, + erasedRecords, + receiptSha256, + ], + ); + return { + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: 'erase', + contributor: CONTRIBUTOR_NAME, + requestId, + erasedRecords, + receiptSha256, + }; + }); + } + + private async verifyWorkspaceErased( + workspaceId: string, + requestId: string, + ): Promise { + const result = await this.client.query( + `SELECT ( + (SELECT count(*) FROM habit.habit_definitions WHERE workspace_id = $1) + + (SELECT count(*) FROM habit.completion_events WHERE workspace_id = $1) + )::integer AS record_count`, + [workspaceId], + ); + const liveRecords = requireInteger( + result.rows[0]?.record_count, + 'record_count', + 0, + ); + const evidenceSha256 = digest( + Object.freeze({ contributor: CONTRIBUTOR_NAME, workspaceId, liveRecords }), + ); + return { + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: 'verify_erased', + contributor: CONTRIBUTOR_NAME, + requestId, + erased: liveRecords === 0, + evidenceSha256, + }; + } +} diff --git a/apps/habit-service/src/habit-runtime.test.ts b/apps/habit-service/src/habit-runtime.test.ts index a191dd0b..8b0a4f43 100644 --- a/apps/habit-service/src/habit-runtime.test.ts +++ b/apps/habit-service/src/habit-runtime.test.ts @@ -4,6 +4,7 @@ import { createHabitPoolConfiguration, createHabitRuntime, type HabitPool, + type HabitPoolConnection, } from './habit-runtime'; const DATABASE_URL = [ @@ -13,6 +14,14 @@ const DATABASE_URL = [ 'life_os', ].join('/'); +class FakeHabitConnection implements HabitPoolConnection { + async query(): Promise<{ rows: Row[] }> { + return { rows: [] }; + } + + release(): void {} +} + class FakeHabitPool implements HabitPool { endCalls = 0; @@ -20,6 +29,10 @@ class FakeHabitPool implements HabitPool { return { rows: [] }; } + async connect(): Promise { + return new FakeHabitConnection(); + } + async end(): Promise { this.endCalls += 1; } diff --git a/apps/habit-service/src/habit-runtime.ts b/apps/habit-service/src/habit-runtime.ts index d7ec5595..2575728f 100644 --- a/apps/habit-service/src/habit-runtime.ts +++ b/apps/habit-service/src/habit-runtime.ts @@ -1,5 +1,9 @@ import type { OnApplicationShutdown } from '@nestjs/common'; -import { Pool, type PoolConfig } from 'pg'; +import { Pool, type PoolClient, type PoolConfig } from 'pg'; +import { + HabitDataRightsContributor, + type HabitTransactionalSqlClient, +} from './habit-data-rights'; import { HabitService } from './habit-domain'; import { type HabitSqlClient, @@ -11,18 +15,44 @@ const MAXIMUM_CONFIGURATION_LENGTH = 8 * 1024; type RuntimeEnvironment = Readonly>; +/** Borrowed PostgreSQL connection used for one Habit-owned transaction. */ +export interface HabitPoolConnection { + query( + text: string, + values?: readonly unknown[], + ): Promise>; + release(): void; +} + /** PostgreSQL pool boundary owned by the Habit service runtime. */ export interface HabitPool { query( text: string, values?: readonly unknown[], ): Promise>; + connect(): Promise; end(): Promise; } /** Factory boundary used to construct a validated Habit database pool. */ export type HabitPoolFactory = (configuration: PoolConfig) => HabitPool; +class NodePostgresHabitPoolConnection implements HabitPoolConnection { + constructor(private readonly connection: PoolClient) {} + + async query( + text: string, + values: readonly unknown[] = [], + ): Promise> { + const result = await this.connection.query(text, [...values]); + return { rows: result.rows as Row[] }; + } + + release(): void { + this.connection.release(); + } +} + class NodePostgresHabitPool implements HabitPool { constructor(private readonly pool: Pool) {} @@ -34,12 +64,27 @@ class NodePostgresHabitPool implements HabitPool { return { rows: result.rows as Row[] }; } + async connect(): Promise { + return new NodePostgresHabitPoolConnection(await this.pool.connect()); + } + async end(): Promise { await this.pool.end(); } } -class NodePostgresHabitSqlClient implements HabitSqlClient { +class ConnectionHabitSqlClient implements HabitSqlClient { + constructor(private readonly connection: HabitPoolConnection) {} + + async query( + text: string, + values: readonly unknown[], + ): Promise> { + return await this.connection.query(text, values); + } +} + +class NodePostgresHabitSqlClient implements HabitTransactionalSqlClient { constructor(private readonly pool: HabitPool) {} async query( @@ -48,6 +93,28 @@ class NodePostgresHabitSqlClient implements HabitSqlClient { ): Promise> { return await this.pool.query(text, values); } + + async transaction( + operation: (client: HabitSqlClient) => Promise, + ): Promise { + const connection = await this.pool.connect(); + const transaction = new ConnectionHabitSqlClient(connection); + try { + await transaction.query('BEGIN', []); + const result = await operation(transaction); + await transaction.query('COMMIT', []); + return result; + } catch (error) { + try { + await transaction.query('ROLLBACK', []); + } catch { + // Preserve the original application or database failure. + } + throw error; + } finally { + connection.release(); + } + } } function requireConfiguration( @@ -128,13 +195,15 @@ function defaultPoolFactory(configuration: PoolConfig): HabitPool { return new NodePostgresHabitPool(new Pool(configuration)); } -/** Owns the Habit service and closes its PostgreSQL pool exactly once. */ +/** Owns Habit service components and closes their PostgreSQL pool exactly once. */ export class HabitRuntime implements OnApplicationShutdown { private closed = false; constructor( private readonly pool: HabitPool, readonly service: HabitService, + /** Service-owned export/erasure participant consumed by Identity orchestration. */ + readonly dataRightsContributor: HabitDataRightsContributor, ) {} async close(): Promise { @@ -156,8 +225,11 @@ export function createHabitRuntime( poolFactory: HabitPoolFactory = defaultPoolFactory, ): HabitRuntime { const pool = poolFactory(createHabitPoolConfiguration(environment)); - const repository = new PostgresHabitRepository( - new NodePostgresHabitSqlClient(pool), + const sqlClient = new NodePostgresHabitSqlClient(pool); + const repository = new PostgresHabitRepository(sqlClient); + return new HabitRuntime( + pool, + new HabitService(repository), + new HabitDataRightsContributor(sqlClient), ); - return new HabitRuntime(pool, new HabitService(repository)); }