Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ PLANNING_SERVICE_ORIGIN=http://127.0.0.1:4102
PLANNING_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes
HABIT_SERVICE_ORIGIN=http://127.0.0.1:4103
HABIT_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes
HABIT_DATA_RIGHTS_CONTEXT_SECRET=replace-with-distinct-at-least-32-random-bytes
AI_SERVICE_ORIGIN=http://127.0.0.1:4105
AI_GATEWAY_ACTIVE_KEY_ID=gateway-2026-08-a
AI_GATEWAY_ACTIVE_KEY_SECRET=replace-with-at-least-32-random-bytes
Expand Down
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;
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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 apps/habit-service/src/habit-data-rights-authority-replay.test.ts
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 apps/habit-service/src/habit-data-rights-authority-replay.ts
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;
}
}
Loading
Loading