-
Notifications
You must be signed in to change notification settings - Fork 0
feat(habit): expose trusted data-rights contributor transport #192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
a0f6c88
test(habit): require exact data-rights transport authority
seonghobae 694054b
feat(habit): verify trusted data-rights contributor requests
seonghobae b330f71
feat(habit): expose authenticated data-rights contributor transport
seonghobae d5ef6e4
test(habit): enforce contributor authority before persistence
seonghobae 7f9e441
test(habit): assert bounded data-rights transport failures
seonghobae d3e6dca
chore(habit): declare dedicated data-rights trust secret
seonghobae 16a51d0
test(habit): expose data-rights replay and error leaks
seonghobae 9367274
fix(habit): atomically consume destructive data-rights authority
seonghobae d6d1933
test(habit): remove unused replay test scaffold
seonghobae 4731532
test(habit): derive replay digest from signed evidence
seonghobae 9833ce7
refactor(habit): depend on replay guard port
seonghobae c64b80d
Merge branch 'main' into feat/habit-data-rights-http-v1
github-actions[bot] dfa273d
test(habit): prove replay store failures stay sanitized
seonghobae 4b239c3
test(habit): cover non-canonical replay expiry instants
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
29 changes: 29 additions & 0 deletions
29
apps/habit-service/migrations/0003_data_rights_authority_replay.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| BEGIN; | ||
|
|
||
| CREATE TABLE habit.data_rights_authority_replay_records ( | ||
| evidence_digest text NOT NULL, | ||
| consumed_at timestamptz NOT NULL DEFAULT now(), | ||
| expires_at timestamptz NOT NULL, | ||
| CONSTRAINT data_rights_authority_replay_records_primary | ||
| PRIMARY KEY (evidence_digest), | ||
| CONSTRAINT data_rights_authority_replay_records_digest_sha256 CHECK ( | ||
| evidence_digest ~ '^[0-9a-f]{64}$' | ||
| ), | ||
| CONSTRAINT data_rights_authority_replay_records_expiry_order CHECK ( | ||
| expires_at >= consumed_at | ||
| ) | ||
| ); | ||
|
|
||
| CREATE INDEX data_rights_authority_replay_expiry_index | ||
| ON habit.data_rights_authority_replay_records (expires_at); | ||
|
|
||
| COMMENT ON TABLE habit.data_rights_authority_replay_records IS | ||
| 'Habit-owned one-time evidence for destructive data-rights HTTP authority.'; | ||
| COMMENT ON COLUMN habit.data_rights_authority_replay_records.evidence_digest IS | ||
| 'SHA-256 digest of one canonical validated HMAC proof; raw authorization evidence is never persisted.'; | ||
| COMMENT ON COLUMN habit.data_rights_authority_replay_records.consumed_at IS | ||
| 'Database-clock instant at which the winning Habit service instance consumed the destructive authority.'; | ||
| COMMENT ON COLUMN habit.data_rights_authority_replay_records.expires_at IS | ||
| 'End of the signed authority lifetime after which the replay record can be pruned.'; | ||
|
|
||
| COMMIT; |
104 changes: 104 additions & 0 deletions
104
apps/habit-service/src/habit-data-rights-authority-replay.integration.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import { createHash } 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 { PostgresHabitDataRightsAuthorityReplayGuard } from './habit-data-rights-authority-replay'; | ||
| import type { | ||
| HabitSqlClient, | ||
| HabitSqlQueryResult, | ||
| } 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<Row>( | ||
| text: string, | ||
| values: readonly unknown[], | ||
| ): Promise<HabitSqlQueryResult<Row>> { | ||
| 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<void> { | ||
| for (const migration of [ | ||
| '0001_recurring_habit_core.sql', | ||
| '0002_data_rights_erasure.sql', | ||
| '0003_data_rights_authority_replay.sql', | ||
| ]) { | ||
| const sql = await readFile( | ||
| resolve(__dirname, '../migrations', migration), | ||
| 'utf8', | ||
| ); | ||
| await pool.query(sql); | ||
| } | ||
| } | ||
|
|
||
| describeWithPostgres('Habit data-rights authority replay PostgreSQL integration', () => { | ||
| beforeAll(async () => { | ||
| administrativePool = new Pool({ | ||
| connectionString: requireDatabaseUrl(), | ||
| application_name: 'life-os-habit-data-rights-replay-test', | ||
| max: 4, | ||
| }); | ||
| }); | ||
|
|
||
| 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('allows exactly one concurrent winner and never persists raw authorization evidence', async () => { | ||
| const guard = new PostgresHabitDataRightsAuthorityReplayGuard( | ||
| new PoolSqlClient(administrativePool), | ||
| ); | ||
| const rawSignature = 'sensitive-short-lived-proof'; | ||
| const evidenceDigest = createHash('sha256') | ||
| .update(rawSignature, 'ascii') | ||
| .digest('hex'); | ||
| const expiresAt = new Date(Date.now() + 60_000).toISOString(); | ||
|
|
||
| const results = await Promise.all([ | ||
| guard.consume({ evidenceDigest, expiresAt }), | ||
| guard.consume({ evidenceDigest, expiresAt }), | ||
| ]); | ||
| expect(results.sort()).toEqual([false, true]); | ||
|
|
||
| const stored = await administrativePool.query( | ||
| `SELECT evidence_digest, consumed_at, expires_at | ||
| FROM habit.data_rights_authority_replay_records`, | ||
| ); | ||
| expect(stored.rows).toHaveLength(1); | ||
| expect(stored.rows[0]?.evidence_digest).toBe(evidenceDigest); | ||
| expect(JSON.stringify(stored.rows)).not.toContain(rawSignature); | ||
| }); | ||
|
|
||
| it('rejects already expired evidence using the database clock', async () => { | ||
| const guard = new PostgresHabitDataRightsAuthorityReplayGuard( | ||
| new PoolSqlClient(administrativePool), | ||
| ); | ||
| await expect( | ||
| guard.consume({ | ||
| evidenceDigest: 'b'.repeat(64), | ||
| expiresAt: new Date(Date.now() - 60_000).toISOString(), | ||
| }), | ||
| ).resolves.toBe(false); | ||
| }); | ||
| }); | ||
82 changes: 82 additions & 0 deletions
82
apps/habit-service/src/habit-data-rights-authority-replay.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import { | ||
| HabitDataRightsAuthorityReplayError, | ||
| PostgresHabitDataRightsAuthorityReplayGuard, | ||
| } from './habit-data-rights-authority-replay'; | ||
| import type { HabitSqlClient } from './postgres-habit-repository'; | ||
|
|
||
| const DIGEST = 'a'.repeat(64); | ||
| const EXPIRES_AT = '2026-08-12T00:01:00.000Z'; | ||
|
|
||
| function clientWith(rows: readonly unknown[]): HabitSqlClient & { | ||
| query: ReturnType<typeof vi.fn>; | ||
| } { | ||
| const query = vi | ||
| .fn() | ||
| .mockResolvedValueOnce({ rows: [] }) | ||
| .mockResolvedValueOnce({ rows }); | ||
| return { query } as unknown as HabitSqlClient & { | ||
| query: ReturnType<typeof vi.fn>; | ||
| }; | ||
| } | ||
|
|
||
| describe('PostgresHabitDataRightsAuthorityReplayGuard', () => { | ||
| it('uses database time and fixed parameterized SQL to atomically consume the first digest', async () => { | ||
| const client = clientWith([{ evidence_digest: DIGEST }]); | ||
| const guard = new PostgresHabitDataRightsAuthorityReplayGuard(client); | ||
|
|
||
| await expect( | ||
| guard.consume({ evidenceDigest: DIGEST, expiresAt: EXPIRES_AT }), | ||
| ).resolves.toBe(true); | ||
| expect(client.query).toHaveBeenNthCalledWith( | ||
| 1, | ||
| expect.stringContaining('WHERE expires_at < now()'), | ||
| [], | ||
| ); | ||
| expect(client.query).toHaveBeenNthCalledWith( | ||
| 2, | ||
| expect.stringContaining('ON CONFLICT (evidence_digest) DO NOTHING'), | ||
| [DIGEST, EXPIRES_AT], | ||
| ); | ||
| expect(client.query.mock.calls[1]?.[0]).toContain( | ||
| 'WHERE $2::timestamptz >= now()', | ||
| ); | ||
| }); | ||
|
|
||
| it('returns false when the digest already exists or database time says it expired', async () => { | ||
| const guard = new PostgresHabitDataRightsAuthorityReplayGuard(clientWith([])); | ||
| await expect( | ||
| guard.consume({ evidenceDigest: DIGEST, expiresAt: EXPIRES_AT }), | ||
| ).resolves.toBe(false); | ||
| }); | ||
|
|
||
| it('rejects malformed caller evidence before any SQL authority is invoked', async () => { | ||
| const query = vi.fn(); | ||
| const client = { query } as unknown as HabitSqlClient; | ||
| const guard = new PostgresHabitDataRightsAuthorityReplayGuard(client); | ||
|
|
||
| for (const evidence of [ | ||
| { evidenceDigest: 'not-a-digest', expiresAt: EXPIRES_AT }, | ||
| { evidenceDigest: DIGEST, expiresAt: '2026-08-12' }, | ||
| { evidenceDigest: DIGEST, expiresAt: '2026-02-30T00:00:00.000Z' }, | ||
| ]) { | ||
| await expect(guard.consume(evidence)).rejects.toBeInstanceOf( | ||
| HabitDataRightsAuthorityReplayError, | ||
| ); | ||
| } | ||
| expect(query).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('rejects ambiguous or corrupted INSERT evidence instead of granting authority', async () => { | ||
| for (const rows of [ | ||
| [{ evidence_digest: 'b'.repeat(64) }], | ||
| [{ evidence_digest: DIGEST }, { evidence_digest: DIGEST }], | ||
| [{ evidence_digest: null }], | ||
| ]) { | ||
| const guard = new PostgresHabitDataRightsAuthorityReplayGuard(clientWith(rows)); | ||
| await expect( | ||
| guard.consume({ evidenceDigest: DIGEST, expiresAt: EXPIRES_AT }), | ||
| ).rejects.toBeInstanceOf(HabitDataRightsAuthorityReplayError); | ||
| } | ||
| }); | ||
| }); |
105 changes: 105 additions & 0 deletions
105
apps/habit-service/src/habit-data-rights-authority-replay.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import type { HabitSqlClient } from './postgres-habit-repository'; | ||
|
|
||
| const SHA_256_PATTERN = /^[0-9a-f]{64}$/u; | ||
| const ISO_INSTANT_PATTERN = | ||
| /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; | ||
|
|
||
| /** One credential-free digest identifying a single destructive signed authority. */ | ||
| export interface HabitDataRightsAuthorityReplayEvidence { | ||
| readonly evidenceDigest: string; | ||
| readonly expiresAt: string; | ||
| } | ||
|
|
||
| /** Habit-owned persistence authority that atomically consumes destructive request evidence once. */ | ||
| export interface HabitDataRightsAuthorityReplayGuardPort { | ||
| /** Returns true only for the first still-live durable consumption of this evidence digest. */ | ||
| consume(evidence: HabitDataRightsAuthorityReplayEvidence): Promise<boolean>; | ||
| } | ||
|
|
||
| interface ReplayEvidenceRow { | ||
| readonly evidence_digest: unknown; | ||
| } | ||
|
|
||
| /** Bounded failure for malformed replay evidence or ambiguous persistence results. */ | ||
| export class HabitDataRightsAuthorityReplayError extends Error { | ||
| /** Creates a credential-free replay-store failure. */ | ||
| constructor() { | ||
| super('Habit data-rights replay evidence is invalid'); | ||
| this.name = 'HabitDataRightsAuthorityReplayError'; | ||
| } | ||
| } | ||
|
|
||
| /** Rejects malformed replay evidence without reflecting caller-controlled values. */ | ||
| function invalid(): never { | ||
| throw new HabitDataRightsAuthorityReplayError(); | ||
| } | ||
|
|
||
| /** Requires one lowercase SHA-256 digest so raw short-lived signatures never enter persistence. */ | ||
| function requireDigest(value: unknown): string { | ||
| if (typeof value !== 'string' || !SHA_256_PATTERN.test(value)) { | ||
| return invalid(); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| /** Requires a canonical UTC millisecond instant for the replay-retention deadline. */ | ||
| function requireInstant(value: unknown): string { | ||
| if (typeof value !== 'string' || !ISO_INSTANT_PATTERN.test(value)) { | ||
| return invalid(); | ||
| } | ||
| const parsed = new Date(value); | ||
| if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== value) { | ||
| return invalid(); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| /** | ||
| * PostgreSQL compare-and-set guard for destructive Habit data-rights authority. | ||
| * | ||
| * The table primary key makes the first still-live evidence digest the sole winner | ||
| * across service replicas. Raw signatures are never stored. PostgreSQL `now()` is | ||
| * authoritative for both pruning and the insertion lifetime check, preventing an | ||
| * application-clock lag from deleting and re-accepting an already expired proof. | ||
| */ | ||
| export class PostgresHabitDataRightsAuthorityReplayGuard | ||
| implements HabitDataRightsAuthorityReplayGuardPort | ||
| { | ||
| /** Creates the guard over the Habit service's bounded parameterized SQL client. */ | ||
| constructor(private readonly client: HabitSqlClient) {} | ||
|
|
||
| /** Atomically consumes one validated digest, returning false for replay or expiry. */ | ||
| async consume( | ||
| evidence: HabitDataRightsAuthorityReplayEvidence, | ||
| ): Promise<boolean> { | ||
| const evidenceDigest = requireDigest(evidence.evidenceDigest); | ||
| const expiresAt = requireInstant(evidence.expiresAt); | ||
|
|
||
| await this.client.query( | ||
| `DELETE FROM habit.data_rights_authority_replay_records | ||
| WHERE expires_at < now()`, | ||
| [], | ||
| ); | ||
| const inserted = await this.client.query<ReplayEvidenceRow>( | ||
| `INSERT INTO habit.data_rights_authority_replay_records ( | ||
| evidence_digest, expires_at | ||
| ) | ||
| SELECT $1, $2::timestamptz | ||
| WHERE $2::timestamptz >= now() | ||
| ON CONFLICT (evidence_digest) DO NOTHING | ||
| RETURNING evidence_digest`, | ||
| [evidenceDigest, expiresAt], | ||
| ); | ||
|
|
||
| if (inserted.rows.length === 0) { | ||
| return false; | ||
| } | ||
| if ( | ||
| inserted.rows.length !== 1 || | ||
| requireDigest(inserted.rows[0]?.evidence_digest) !== evidenceDigest | ||
| ) { | ||
| return invalid(); | ||
| } | ||
| return true; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.