diff --git a/.github/workflows/verify-plugin-delivery-execution-fence-restack.yml b/.github/workflows/verify-plugin-delivery-execution-fence-restack.yml new file mode 100644 index 000000000..3b4958da1 --- /dev/null +++ b/.github/workflows/verify-plugin-delivery-execution-fence-restack.yml @@ -0,0 +1,87 @@ +name: Verify Plugin Delivery Execution Fence Restack + +on: + push: + branches: + - feat/plugin-delivery-attempt-execution-fence-v1 + +permissions: + contents: read + +concurrency: + group: life-os-plugin-delivery-execution-fence-restack + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-24.04 + timeout-minutes: 40 + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_DB: life_os_integration + POSTGRES_USER: life_os + POSTGRES_PASSWORD: life_os_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U life_os -d life_os_integration" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + env: + INTEGRATION_DATABASE_URL: postgresql://life_os:life_os_test@127.0.0.1:5432/life_os_integration?sslmode=disable + steps: + - name: Checkout exact branch head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + fetch-depth: 2 + persist-credentials: false + - name: Guard current exact publication + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + read -r remote_head _ < <(git ls-remote --refs origin "refs/heads/$GITHUB_REF_NAME") + test "$remote_head" = "$GITHUB_SHA" + test ! -e .github/workflows/repair-plugin-delivery-execution-fence-formatting.yml + - name: Set up Node 24 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: '24' + - name: Enable repository pnpm + run: corepack enable + - name: Install frozen workspace + run: pnpm install --frozen-lockfile + - name: Build Plugin SDK runtime entry + run: pnpm --filter @life-os/plugin-sdk build + - name: Check execution-fence formatting + run: >- + pnpm exec prettier --check + apps/integration-service/src/plugin-delivery-attempt-execution-fence-repository.ts + apps/integration-service/src/plugin-delivery-attempt-execution-fence.integration.test.ts + apps/integration-service/src/plugin-delivery-attempt-execution-fence.test.ts + apps/integration-service/src/plugin-delivery-attempt-execution-fence.ts + - name: Check diff hygiene + run: git diff --check HEAD^ + - name: Run adopted control acceptance + run: >- + pnpm --filter @life-os/integration-service exec vitest run + src/plugin-delivery-attempt-outcome.integration.test.ts + src/plugin-delivery-attempt-outcome-forgery.integration.test.ts + src/plugin-delivery-attempt-control.test.ts + src/plugin-delivery-attempt-control.integration.test.ts + src/plugin-delivery-attempt-control-chronology.integration.test.ts + --no-file-parallelism + - name: Run execution-fence acceptance + run: >- + pnpm --filter @life-os/integration-service exec vitest run + src/plugin-delivery-attempt-execution-fence.test.ts + src/plugin-delivery-attempt-execution-fence.integration.test.ts + --no-file-parallelism + - name: Typecheck Integration service + run: pnpm --filter @life-os/integration-service typecheck + - name: Run complete Integration service suite + run: pnpm --filter @life-os/integration-service test diff --git a/apps/integration-service/src/plugin-delivery-attempt-execution-fence-repository.ts b/apps/integration-service/src/plugin-delivery-attempt-execution-fence-repository.ts new file mode 100644 index 000000000..b3bd36def --- /dev/null +++ b/apps/integration-service/src/plugin-delivery-attempt-execution-fence-repository.ts @@ -0,0 +1,321 @@ +import type { + PluginDeliveryAttemptExecutionFenceCommand, + PluginDeliveryAttemptExecutionFenceEvidence, + PluginDeliveryAttemptExecutionFenceStore, +} from './plugin-delivery-attempt-execution-fence'; + +const FENCE_AUTHORITY_VERSION = + 'life-os.plugin-delivery-attempt-execution-fence.v1' as const; +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}$/u; +const SHA256_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; +const MAXIMUM_ATTEMPT_NUMBER = 10; + +/** Result returned by the bounded execution-fence SQL client. */ +export interface PluginDeliveryAttemptExecutionFenceSqlResult { + readonly rows: readonly Row[]; + readonly rowCount: number | null; +} + +/** Minimal parameterized SQL authority required by the execution-fence store. */ +export interface PluginDeliveryAttemptExecutionFenceSqlClient { + query( + text: string, + values?: readonly unknown[], + ): Promise>; +} + +/** Rejects malformed fence input before any SQL authority is exercised. */ +export class PluginDeliveryAttemptExecutionFencePersistenceValidationError extends Error { + /** Creates a fixed input failure without reflecting command data. */ + constructor() { + super( + 'Plugin delivery attempt execution fence persistence input is invalid', + ); + this.name = 'PluginDeliveryAttemptExecutionFencePersistenceValidationError'; + } +} + +/** Rejects ambiguous or corrupt durable fence evidence. */ +export class PluginDeliveryAttemptExecutionFencePersistenceEvidenceError extends Error { + /** Creates a fixed evidence failure without reflecting database detail. */ + constructor() { + super( + 'Persisted plugin delivery attempt execution fence evidence is invalid', + ); + this.name = 'PluginDeliveryAttemptExecutionFencePersistenceEvidenceError'; + } +} + +interface ExecutionFenceRow { + authority_version: unknown; + delivery_id: unknown; + grant_id: unknown; + installation_id: unknown; + workspace_id: unknown; + requested_by_user_id: unknown; + attempt_count: unknown; + checked_at: unknown; + claim_expires_at: unknown; +} + +function invalidInput(): never { + throw new PluginDeliveryAttemptExecutionFencePersistenceValidationError(); +} + +function invalidEvidence(): never { + throw new PluginDeliveryAttemptExecutionFencePersistenceEvidenceError(); +} + +function boundedInputRead(read: () => T): T { + try { + return read(); + } catch { + return invalidInput(); + } +} + +function boundedEvidenceRead(read: () => T): T { + try { + return read(); + } catch { + return invalidEvidence(); + } +} + +async function boundedEvidenceDependency( + read: () => Promise, +): Promise { + try { + return await read(); + } catch { + return invalidEvidence(); + } +} + +function requireInputUuid(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + return invalidInput(); + } + return value; +} + +function requireStoredUuid(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + return invalidEvidence(); + } + return value; +} + +function requireInputInstant(value: unknown): string { + if (typeof value !== 'string' || !ISO_INSTANT_PATTERN.test(value)) { + return invalidInput(); + } + const instant = new Date(value); + if (!Number.isFinite(instant.getTime()) || instant.toISOString() !== value) { + return invalidInput(); + } + return value; +} + +function requireStoredInstant(value: unknown): string { + const candidate = boundedEvidenceRead(() => + value instanceof Date ? value.toISOString() : value, + ); + if (typeof candidate !== 'string' || !ISO_INSTANT_PATTERN.test(candidate)) { + return invalidEvidence(); + } + const instant = new Date(candidate); + if ( + !Number.isFinite(instant.getTime()) || + instant.toISOString() !== candidate + ) { + return invalidEvidence(); + } + return candidate; +} + +function validateCommand( + value: PluginDeliveryAttemptExecutionFenceCommand, +): PluginDeliveryAttemptExecutionFenceCommand { + if (value === null || typeof value !== 'object') { + return invalidInput(); + } + if (boundedInputRead(() => Array.isArray(value))) { + return invalidInput(); + } + const command = value as PluginDeliveryAttemptExecutionFenceCommand; + const snapshot = boundedInputRead(() => ({ + deliveryId: command.deliveryId, + workspaceId: command.workspaceId, + requestedByUserId: command.requestedByUserId, + claimTokenDigest: command.claimTokenDigest, + checkedAt: command.checkedAt, + })); + if ( + typeof snapshot.claimTokenDigest !== 'string' || + !SHA256_PATTERN.test(snapshot.claimTokenDigest) + ) { + return invalidInput(); + } + return Object.freeze({ + deliveryId: requireInputUuid(snapshot.deliveryId), + workspaceId: requireInputUuid(snapshot.workspaceId), + requestedByUserId: requireInputUuid(snapshot.requestedByUserId), + claimTokenDigest: snapshot.claimTokenDigest, + checkedAt: requireInputInstant(snapshot.checkedAt), + }); +} + +function singleRow( + result: PluginDeliveryAttemptExecutionFenceSqlResult, +): Row | undefined { + if (result === null || typeof result !== 'object') { + return invalidEvidence(); + } + if (boundedEvidenceRead(() => Array.isArray(result))) { + return invalidEvidence(); + } + const [rows, rowCount] = boundedEvidenceRead( + () => [result.rows, result.rowCount] as const, + ); + if (!boundedEvidenceRead(() => Array.isArray(rows))) { + return invalidEvidence(); + } + const rowsLength = boundedEvidenceRead(() => rows.length); + if ( + typeof rowCount !== 'number' || + !Number.isInteger(rowCount) || + rowCount < 0 || + rowCount !== rowsLength || + rowsLength > 1 + ) { + return invalidEvidence(); + } + if (rowsLength === 0) { + return undefined; + } + return boundedEvidenceRead(() => rows[0]); +} + +function parseEvidence( + row: unknown, + command: PluginDeliveryAttemptExecutionFenceCommand, +): PluginDeliveryAttemptExecutionFenceEvidence { + if (row === null || typeof row !== 'object') { + return invalidEvidence(); + } + if (boundedEvidenceRead(() => Array.isArray(row))) { + return invalidEvidence(); + } + const candidate = row as ExecutionFenceRow; + const snapshot = boundedEvidenceRead(() => ({ + authorityVersion: candidate.authority_version, + deliveryId: candidate.delivery_id, + grantId: candidate.grant_id, + installationId: candidate.installation_id, + workspaceId: candidate.workspace_id, + requestedByUserId: candidate.requested_by_user_id, + attemptNumber: candidate.attempt_count, + checkedAt: candidate.checked_at, + claimExpiresAt: candidate.claim_expires_at, + })); + if ( + snapshot.authorityVersion !== FENCE_AUTHORITY_VERSION || + snapshot.deliveryId !== command.deliveryId || + snapshot.workspaceId !== command.workspaceId || + snapshot.requestedByUserId !== command.requestedByUserId || + typeof snapshot.attemptNumber !== 'number' || + !Number.isInteger(snapshot.attemptNumber) || + snapshot.attemptNumber < 1 || + snapshot.attemptNumber > MAXIMUM_ATTEMPT_NUMBER + ) { + return invalidEvidence(); + } + const checkedAt = requireStoredInstant(snapshot.checkedAt); + const claimExpiresAt = requireStoredInstant(snapshot.claimExpiresAt); + if ( + checkedAt !== command.checkedAt || + new Date(claimExpiresAt).getTime() <= new Date(checkedAt).getTime() + ) { + return invalidEvidence(); + } + return Object.freeze({ + authorityVersion: FENCE_AUTHORITY_VERSION, + deliveryId: requireStoredUuid(snapshot.deliveryId), + grantId: requireStoredUuid(snapshot.grantId), + installationId: requireStoredUuid(snapshot.installationId), + workspaceId: requireStoredUuid(snapshot.workspaceId), + requestedByUserId: requireStoredUuid(snapshot.requestedByUserId), + attemptNumber: snapshot.attemptNumber, + checkedAt, + claimExpiresAt, + }); +} + +/** PostgreSQL adapter for immediate Integration-owned delivery execution authority. */ +export class PostgresPluginDeliveryAttemptExecutionFenceStore implements PluginDeliveryAttemptExecutionFenceStore { + /** Creates the store over one bounded parameterized SQL client. */ + constructor( + private readonly client: PluginDeliveryAttemptExecutionFenceSqlClient, + ) {} + + /** Revalidates the exact live claim, origin grant, and owning installation. */ + async check( + commandValue: PluginDeliveryAttemptExecutionFenceCommand, + ): Promise { + const command = validateCommand(commandValue); + const result = await boundedEvidenceDependency(() => + this.client.query( + `SELECT 'life-os.plugin-delivery-attempt-execution-fence.v1'::text AS authority_version, + attempt.delivery_id, + attempt.grant_id, + attempt.installation_id, + attempt.workspace_id, + attempt.requested_by_user_id, + attempt.attempt_count, + $5::timestamptz AS checked_at, + attempt.claim_expires_at + FROM plugin_integration.plugin_delivery_attempt_record AS attempt + JOIN plugin_integration.plugin_delivery_origin_grant_record AS grant_record + ON grant_record.grant_id = attempt.grant_id + AND grant_record.installation_id = attempt.installation_id + AND grant_record.workspace_id = attempt.workspace_id + AND grant_record.granted_by_user_id = attempt.requested_by_user_id + JOIN plugin_integration.plugin_installation_record AS installation_record + ON installation_record.installation_id = attempt.installation_id + AND installation_record.workspace_id = attempt.workspace_id + AND installation_record.installed_by_user_id = attempt.requested_by_user_id + WHERE attempt.delivery_id = $1::uuid + AND attempt.workspace_id = $2::uuid + AND attempt.requested_by_user_id = $3::uuid + AND attempt.claim_token_digest = $4 + AND attempt.delivery_status = 'pending' + AND attempt.attempt_count BETWEEN 1 AND attempt.max_attempts + AND attempt.claim_started_at IS NOT NULL + AND attempt.claim_started_at <= $5::timestamptz + AND attempt.claim_expires_at > $5::timestamptz + AND grant_record.grant_status = 'active' + AND grant_record.revoked_at IS NULL + AND grant_record.granted_at <= $5::timestamptz + AND installation_record.installation_status = 'active' + AND installation_record.revoked_at IS NULL + AND installation_record.installed_at <= $5::timestamptz + LIMIT 2`, + [ + command.deliveryId, + command.workspaceId, + command.requestedByUserId, + command.claimTokenDigest, + command.checkedAt, + ], + ), + ); + const row = singleRow(result); + if (row === undefined) { + return undefined; + } + return parseEvidence(row, command); + } +} diff --git a/apps/integration-service/src/plugin-delivery-attempt-execution-fence.integration.test.ts b/apps/integration-service/src/plugin-delivery-attempt-execution-fence.integration.test.ts new file mode 100644 index 000000000..52629c6a6 --- /dev/null +++ b/apps/integration-service/src/plugin-delivery-attempt-execution-fence.integration.test.ts @@ -0,0 +1,204 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Pool, type QueryResultRow } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + PluginDeliveryAttemptExecutionFenceApplication, + PluginDeliveryAttemptExecutionFenceAuthorityError, +} from './plugin-delivery-attempt-execution-fence'; +import type { + PluginDeliveryAttemptExecutionFenceSqlClient, + PluginDeliveryAttemptExecutionFenceSqlResult, +} from './plugin-delivery-attempt-execution-fence-repository'; +import { PostgresPluginDeliveryAttemptExecutionFenceStore } from './plugin-delivery-attempt-execution-fence-repository'; +import { parsePluginDeliveryAttemptTestDatabaseTarget } from './plugin-delivery-attempt-test-database'; + +const DATABASE_URL = process.env.INTEGRATION_DATABASE_URL; +const TEST_DATABASE_TARGET = DATABASE_URL + ? parsePluginDeliveryAttemptTestDatabaseTarget(DATABASE_URL) + : undefined; +const describeWithPostgres = TEST_DATABASE_TARGET ? describe : describe.skip; +const MIGRATIONS = [ + '0001_plugin_installation_record.sql', + '0002_plugin_credential_binding_record.sql', + '0003_plugin_operator_context_replay_record.sql', + '0004_plugin_delivery_origin_grant_record.sql', + '0005_plugin_credential_active_installation_guard.sql', + '0006_plugin_delivery_attempt_record.sql', + '0007_plugin_delivery_attempt_claim_lease.sql', + '0008_plugin_delivery_attempt_retry_transition.sql', + '0009_plugin_delivery_attempt_outcome_record.sql', + '0010_plugin_delivery_attempt_control_lifecycle.sql', + '0011_plugin_delivery_attempt_control_chronology_guard.sql', +].map((name) => + readFileSync(join(__dirname, '..', 'migrations', name), 'utf8'), +); + +const DELIVERY_ID = '55555555-5555-4555-8555-555555555555'; +const GRANT_ID = '11111111-1111-4111-8111-111111111111'; +const INSTALLATION_ID = '22222222-2222-4222-8222-222222222222'; +const WORKSPACE_ID = '33333333-3333-4333-8333-333333333333'; +const USER_ID = '44444444-4444-4444-8444-444444444444'; +const CLAIM_TOKEN = '66666666-6666-4666-8666-666666666666'; +const CLAIM_TOKEN_DIGEST = + 'a9703d75e61670054471bf04ee63439c365fcb5f0c54dcb9d8d44ffb30cc56a1'; +const CHECKED_AT = '2026-09-09T02:00:00.000Z'; +const CLAIM_EXPIRES_AT = '2026-09-09T02:05:00.000Z'; + +class PoolSqlClient implements PluginDeliveryAttemptExecutionFenceSqlClient { + 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, rowCount: result.rowCount }; + } +} + +let pool: Pool; + +beforeAll(() => { + if (TEST_DATABASE_TARGET && DATABASE_URL) { + pool = new Pool({ connectionString: DATABASE_URL, max: 4 }); + } +}); + +afterAll(async () => { + if (pool) { + await pool.end(); + } +}); + +async function prepareClaimedAttempt(): Promise { + await pool.query('DROP SCHEMA IF EXISTS plugin_integration CASCADE;'); + for (const migration of MIGRATIONS) { + await pool.query(migration); + } + await pool.query(` + INSERT INTO plugin_integration.plugin_installation_record ( + installation_id, workspace_id, installed_by_user_id, plugin_id, + plugin_contract_version, manifest_sha256, granted_capabilities, + installation_status, installed_at, revoked_at + ) VALUES ( + '${INSTALLATION_ID}', '${WORKSPACE_ID}', '${USER_ID}', 'example.plugin', + '1.0.0', repeat('a', 64), ARRAY['delivery.https'], 'active', + '2026-09-09T01:00:00.000Z', NULL + ); + INSERT INTO plugin_integration.plugin_delivery_origin_grant_record ( + authority_version, grant_id, installation_id, workspace_id, + granted_by_user_id, origin_uri, grant_status, granted_at, revoked_at + ) VALUES ( + 'life-os.plugin-delivery-origin.v1', '${GRANT_ID}', '${INSTALLATION_ID}', + '${WORKSPACE_ID}', '${USER_ID}', 'https://api.example.com', 'active', + '2026-09-09T01:10:00.000Z', NULL + ); + INSERT INTO plugin_integration.plugin_delivery_attempt_record ( + authority_version, delivery_id, grant_id, installation_id, workspace_id, + requested_by_user_id, delivery_status, attempt_count, max_attempts, + requested_at, updated_at, next_attempt_at, terminal_at, last_outcome_code, + claim_token_digest, claim_started_at, claim_expires_at + ) VALUES ( + 'life-os.plugin-delivery-attempt.v1', '${DELIVERY_ID}', '${GRANT_ID}', + '${INSTALLATION_ID}', '${WORKSPACE_ID}', '${USER_ID}', 'pending', 1, 3, + '2026-09-09T01:20:00.000Z', '2026-09-09T01:55:00.000Z', + '2026-09-09T01:20:00.000Z', NULL, NULL, '${CLAIM_TOKEN_DIGEST}', + '2026-09-09T01:55:00.000Z', '${CLAIM_EXPIRES_AT}' + ); + `); +} + +function app(): PluginDeliveryAttemptExecutionFenceApplication { + return new PluginDeliveryAttemptExecutionFenceApplication( + new PostgresPluginDeliveryAttemptExecutionFenceStore( + new PoolSqlClient(pool), + ), + () => new Date(CHECKED_AT), + ); +} + +function context() { + return { workspaceId: WORKSPACE_ID, actorUserId: USER_ID }; +} + +async function durableClaimSnapshot() { + const result = await pool.query<{ + attempt_count: number; + claim_token_digest: string; + claim_started_at: Date; + claim_expires_at: Date; + }>( + `SELECT attempt_count, claim_token_digest, claim_started_at, claim_expires_at + FROM plugin_integration.plugin_delivery_attempt_record + WHERE delivery_id = $1::uuid`, + [DELIVERY_ID], + ); + return result.rows.map((row) => ({ + attemptCount: row.attempt_count, + claimTokenDigest: row.claim_token_digest, + claimStartedAt: row.claim_started_at.toISOString(), + claimExpiresAt: row.claim_expires_at.toISOString(), + })); +} + +describeWithPostgres( + 'plugin delivery-attempt pre-execution fence PostgreSQL acceptance', + () => { + beforeEach(async () => { + await prepareClaimedAttempt(); + }); + + it('accepts only the exact live claim with active grant and installation', async () => { + await expect( + app().check(context(), DELIVERY_ID, CLAIM_TOKEN), + ).resolves.toEqual({ + authorityVersion: 'life-os.plugin-delivery-attempt-execution-fence.v1', + deliveryId: DELIVERY_ID, + grantId: GRANT_ID, + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + attemptNumber: 1, + checkedAt: CHECKED_AT, + claimExpiresAt: CLAIM_EXPIRES_AT, + }); + }); + + it('fails closed after delivery-origin revocation without mutating the accepted claim', async () => { + const before = await durableClaimSnapshot(); + await pool.query( + `UPDATE plugin_integration.plugin_delivery_origin_grant_record + SET grant_status = 'revoked', revoked_at = $2::timestamptz + WHERE grant_id = $1::uuid`, + [GRANT_ID, '2026-09-09T01:59:00.000Z'], + ); + + await expect( + app().check(context(), DELIVERY_ID, CLAIM_TOKEN), + ).rejects.toEqual( + new PluginDeliveryAttemptExecutionFenceAuthorityError(), + ); + await expect(durableClaimSnapshot()).resolves.toEqual(before); + }); + + it('fails closed after installation revocation without mutating the accepted claim', async () => { + const before = await durableClaimSnapshot(); + await pool.query( + `UPDATE plugin_integration.plugin_installation_record + SET installation_status = 'revoked', revoked_at = $2::timestamptz + WHERE installation_id = $1::uuid`, + [INSTALLATION_ID, '2026-09-09T01:59:00.000Z'], + ); + + await expect( + app().check(context(), DELIVERY_ID, CLAIM_TOKEN), + ).rejects.toEqual( + new PluginDeliveryAttemptExecutionFenceAuthorityError(), + ); + await expect(durableClaimSnapshot()).resolves.toEqual(before); + }); + }, +); diff --git a/apps/integration-service/src/plugin-delivery-attempt-execution-fence.test.ts b/apps/integration-service/src/plugin-delivery-attempt-execution-fence.test.ts new file mode 100644 index 000000000..edd7e6c41 --- /dev/null +++ b/apps/integration-service/src/plugin-delivery-attempt-execution-fence.test.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + PluginDeliveryAttemptExecutionFenceApplication, + PluginDeliveryAttemptExecutionFenceAuthorityError, + type PluginDeliveryAttemptExecutionFenceCommand, + type PluginDeliveryAttemptExecutionFenceEvidence, + type PluginDeliveryAttemptExecutionFenceStore, +} from './plugin-delivery-attempt-execution-fence'; + +const DELIVERY_ID = '55555555-5555-4555-8555-555555555555'; +const GRANT_ID = '11111111-1111-4111-8111-111111111111'; +const INSTALLATION_ID = '22222222-2222-4222-8222-222222222222'; +const WORKSPACE_ID = '33333333-3333-4333-8333-333333333333'; +const USER_ID = '44444444-4444-4444-8444-444444444444'; +const CLAIM_TOKEN = '66666666-6666-4666-8666-666666666666'; +const CHECKED_AT = '2026-09-09T02:00:00.000Z'; +const CLAIM_EXPIRES_AT = '2026-09-09T02:05:00.000Z'; + +function digest(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} + +class FakeStore implements PluginDeliveryAttemptExecutionFenceStore { + readonly commands: PluginDeliveryAttemptExecutionFenceCommand[] = []; + + constructor( + private readonly result: + PluginDeliveryAttemptExecutionFenceEvidence | undefined | Error, + ) {} + + async check( + command: PluginDeliveryAttemptExecutionFenceCommand, + ): Promise { + this.commands.push(command); + if (this.result instanceof Error) { + throw this.result; + } + return this.result; + } +} + +function context() { + return { workspaceId: WORKSPACE_ID, actorUserId: USER_ID }; +} + +function evidence(): PluginDeliveryAttemptExecutionFenceEvidence { + return { + authorityVersion: 'life-os.plugin-delivery-attempt-execution-fence.v1', + deliveryId: DELIVERY_ID, + grantId: GRANT_ID, + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + attemptNumber: 1, + checkedAt: CHECKED_AT, + claimExpiresAt: CLAIM_EXPIRES_AT, + }; +} + +describe('PluginDeliveryAttemptExecutionFenceApplication', () => { + it('binds an exact raw claim token to one current execution-fence check', async () => { + const store = new FakeStore(evidence()); + const app = new PluginDeliveryAttemptExecutionFenceApplication( + store, + () => new Date(CHECKED_AT), + ); + + await expect( + app.check(context(), DELIVERY_ID, CLAIM_TOKEN), + ).resolves.toEqual(evidence()); + expect(store.commands).toEqual([ + { + deliveryId: DELIVERY_ID, + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + claimTokenDigest: digest(CLAIM_TOKEN), + checkedAt: CHECKED_AT, + }, + ]); + }); + + it('rejects a case-aliased claim token before durable authority lookup', async () => { + const store = new FakeStore(evidence()); + const app = new PluginDeliveryAttemptExecutionFenceApplication( + store, + () => new Date(CHECKED_AT), + ); + + await expect( + app.check(context(), DELIVERY_ID, 'ABCDEFAB-CDEF-4ABC-8DEF-ABCDEFABCDEF'), + ).rejects.toEqual(new PluginDeliveryAttemptExecutionFenceAuthorityError()); + expect(store.commands).toEqual([]); + }); + + it('fails closed when durable authority is absent', async () => { + const app = new PluginDeliveryAttemptExecutionFenceApplication( + new FakeStore(undefined), + () => new Date(CHECKED_AT), + ); + + await expect( + app.check(context(), DELIVERY_ID, CLAIM_TOKEN), + ).rejects.toEqual(new PluginDeliveryAttemptExecutionFenceAuthorityError()); + }); + + it('collapses dependency rejection without reflecting backend detail', async () => { + const app = new PluginDeliveryAttemptExecutionFenceApplication( + new FakeStore(new Error('database-host=private.internal')), + () => new Date(CHECKED_AT), + ); + + await expect( + app.check(context(), DELIVERY_ID, CLAIM_TOKEN), + ).rejects.toEqual(new PluginDeliveryAttemptExecutionFenceAuthorityError()); + }); +}); diff --git a/apps/integration-service/src/plugin-delivery-attempt-execution-fence.ts b/apps/integration-service/src/plugin-delivery-attempt-execution-fence.ts new file mode 100644 index 000000000..42c5d5332 --- /dev/null +++ b/apps/integration-service/src/plugin-delivery-attempt-execution-fence.ts @@ -0,0 +1,215 @@ +import { createHash } from 'node:crypto'; +import type { PluginInstallationContext } from './plugin-installation'; + +const AUTHORITY_VERSION = + 'life-os.plugin-delivery-attempt-execution-fence.v1' as const; +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}$/iu; +const ISO_INSTANT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; +const MINIMUM_ATTEMPT_NUMBER = 1; +const MAXIMUM_ATTEMPT_NUMBER = 10; + +/** Fixed fail-closed execution-fence failure without request or backend reflection. */ +export class PluginDeliveryAttemptExecutionFenceAuthorityError extends Error { + /** Creates one credential- and payload-free authority failure. */ + constructor() { + super('Plugin delivery attempt execution fence authority is invalid'); + this.name = 'PluginDeliveryAttemptExecutionFenceAuthorityError'; + } +} + +/** Exact durable authority lookup immediately before provider execution. */ +export interface PluginDeliveryAttemptExecutionFenceCommand { + readonly deliveryId: string; + readonly workspaceId: string; + readonly requestedByUserId: string; + readonly claimTokenDigest: string; + readonly checkedAt: string; +} + +/** Durable evidence that the claim and its owning authorities are active now. */ +export interface PluginDeliveryAttemptExecutionFenceEvidence { + readonly authorityVersion: typeof AUTHORITY_VERSION; + readonly deliveryId: string; + readonly grantId: string; + readonly installationId: string; + readonly workspaceId: string; + readonly requestedByUserId: string; + readonly attemptNumber: number; + readonly checkedAt: string; + readonly claimExpiresAt: string; +} + +/** Service-owned persistence port for the current pre-execution authority check. */ +export interface PluginDeliveryAttemptExecutionFenceStore { + /** Returns current durable authority or undefined when any required authority is absent. */ + check( + command: PluginDeliveryAttemptExecutionFenceCommand, + ): Promise; +} + +function invalid(): never { + throw new PluginDeliveryAttemptExecutionFenceAuthorityError(); +} + +function boundedRead(read: () => T): T { + try { + return read(); + } catch { + return invalid(); + } +} + +async function boundedDependency(read: () => Promise): Promise { + try { + return await read(); + } catch { + return invalid(); + } +} + +function requireUuidV4(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + return invalid(); + } + return value.toLowerCase(); +} + +function requireInstant(value: unknown): string { + const candidate = boundedRead(() => + value instanceof Date ? value.toISOString() : value, + ); + if (typeof candidate !== 'string' || !ISO_INSTANT_PATTERN.test(candidate)) { + return invalid(); + } + const instant = new Date(candidate); + if ( + !Number.isFinite(instant.getTime()) || + instant.toISOString() !== candidate + ) { + return invalid(); + } + return candidate; +} + +function currentInstant(now: () => Date): string { + try { + return requireInstant(now().toISOString()); + } catch { + return invalid(); + } +} + +function requireContext(value: unknown): PluginInstallationContext { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return invalid(); + } + const context = value as PluginInstallationContext; + const [workspaceId, actorUserId] = boundedRead( + () => [context.workspaceId, context.actorUserId] as const, + ); + return Object.freeze({ + workspaceId: requireUuidV4(workspaceId), + actorUserId: requireUuidV4(actorUserId), + }); +} + +function digestClaimToken(rawClaimToken: unknown): string { + if ( + typeof rawClaimToken !== 'string' || + !UUID_V4_PATTERN.test(rawClaimToken) || + rawClaimToken !== rawClaimToken.toLowerCase() + ) { + return invalid(); + } + return createHash('sha256').update(rawClaimToken, 'utf8').digest('hex'); +} + +function requireEvidence( + value: unknown, + command: PluginDeliveryAttemptExecutionFenceCommand, +): PluginDeliveryAttemptExecutionFenceEvidence { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return invalid(); + } + const evidence = value as PluginDeliveryAttemptExecutionFenceEvidence; + const snapshot = boundedRead(() => ({ + authorityVersion: evidence.authorityVersion, + deliveryId: evidence.deliveryId, + grantId: evidence.grantId, + installationId: evidence.installationId, + workspaceId: evidence.workspaceId, + requestedByUserId: evidence.requestedByUserId, + attemptNumber: evidence.attemptNumber, + checkedAt: evidence.checkedAt, + claimExpiresAt: evidence.claimExpiresAt, + })); + if ( + snapshot.authorityVersion !== AUTHORITY_VERSION || + snapshot.deliveryId !== command.deliveryId || + snapshot.workspaceId !== command.workspaceId || + snapshot.requestedByUserId !== command.requestedByUserId || + typeof snapshot.attemptNumber !== 'number' || + !Number.isInteger(snapshot.attemptNumber) || + snapshot.attemptNumber < MINIMUM_ATTEMPT_NUMBER || + snapshot.attemptNumber > MAXIMUM_ATTEMPT_NUMBER + ) { + return invalid(); + } + const checkedAt = requireInstant(snapshot.checkedAt); + const claimExpiresAt = requireInstant(snapshot.claimExpiresAt); + if ( + checkedAt !== command.checkedAt || + new Date(claimExpiresAt).getTime() <= new Date(checkedAt).getTime() + ) { + return invalid(); + } + return Object.freeze({ + authorityVersion: AUTHORITY_VERSION, + deliveryId: requireUuidV4(snapshot.deliveryId), + grantId: requireUuidV4(snapshot.grantId), + installationId: requireUuidV4(snapshot.installationId), + workspaceId: requireUuidV4(snapshot.workspaceId), + requestedByUserId: requireUuidV4(snapshot.requestedByUserId), + attemptNumber: snapshot.attemptNumber, + checkedAt, + claimExpiresAt, + }); +} + +/** + * Revalidates one claimed delivery immediately before provider execution. + * + * Passing this fence is Integration authority only. It does not grant DNS/IP, + * redirect, proxy, connect, credential, timeout, or response-size authority. + */ +export class PluginDeliveryAttemptExecutionFenceApplication { + /** Creates the application over one Integration-owned durable authority store. */ + constructor( + private readonly store: PluginDeliveryAttemptExecutionFenceStore, + private readonly now: () => Date = () => new Date(), + ) {} + + /** Proves that the exact claim and its owning authorities are still active now. */ + async check( + trustedContext: PluginInstallationContext, + deliveryIdInput: string, + rawClaimToken: string, + ): Promise { + const context = requireContext(trustedContext); + const deliveryId = requireUuidV4(deliveryIdInput); + const checkedAt = currentInstant(this.now); + const command = Object.freeze({ + deliveryId, + workspaceId: context.workspaceId, + requestedByUserId: context.actorUserId, + claimTokenDigest: digestClaimToken(rawClaimToken), + checkedAt, + }); + const evidence = await boundedDependency(() => this.store.check(command)); + if (evidence === undefined) { + return invalid(); + } + return requireEvidence(evidence, command); + } +}